feat(profiles): Add profile-based model configuration v2.8.0
- Add profile system for managing model mappings per use case - New commands: claudish init, claudish profile (list/add/remove/use/show/edit) - Support -p/--profile flag to select profile at runtime - Replace Ink with @inquirer/prompts for better compatibility - Add fuzzy search model selection with @inquirer/search - Config stored at ~/.claudish/config.json - Each profile maps opus/sonnet/haiku/subagent to OpenRouter models - Profile models are applied as defaults, CLI flags override 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
95d716a9e4
commit
a3303a12db
56
bun.lock
56
bun.lock
|
|
@ -6,6 +6,8 @@
|
||||||
"name": "claudish",
|
"name": "claudish",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.19.6",
|
"@hono/node-server": "^1.19.6",
|
||||||
|
"@inquirer/prompts": "^8.0.1",
|
||||||
|
"@inquirer/search": "^4.0.1",
|
||||||
"@modelcontextprotocol/sdk": "^1.22.0",
|
"@modelcontextprotocol/sdk": "^1.22.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"hono": "^4.10.6",
|
"hono": "^4.10.6",
|
||||||
|
|
@ -39,6 +41,38 @@
|
||||||
|
|
||||||
"@hono/node-server": ["@hono/node-server@1.19.6", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw=="],
|
"@hono/node-server": ["@hono/node-server@1.19.6", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw=="],
|
||||||
|
|
||||||
|
"@inquirer/ansi": ["@inquirer/ansi@2.0.1", "", {}, "sha512-QAZUk6BBncv/XmSEZTscd8qazzjV3E0leUMrEPjxCd51QBgCKmprUGLex5DTsNtURm7LMzv+CLcd6S86xvBfYg=="],
|
||||||
|
|
||||||
|
"@inquirer/checkbox": ["@inquirer/checkbox@5.0.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.1", "@inquirer/core": "^11.0.1", "@inquirer/figures": "^2.0.1", "@inquirer/type": "^4.0.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-5VPFBK8jKdsjMK3DTFOlbR0+Kkd4q0AWB7VhWQn6ppv44dr3b7PU8wSJQTC5oA0f/aGW7v/ZozQJAY9zx6PKig=="],
|
||||||
|
|
||||||
|
"@inquirer/confirm": ["@inquirer/confirm@6.0.1", "", { "dependencies": { "@inquirer/core": "^11.0.1", "@inquirer/type": "^4.0.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-wD+pM7IxLn1TdcQN12Q6wcFe5VpyCuh/I2sSmqO5KjWH2R4v+GkUToHb+PsDGobOe1MtAlXMwGNkZUPc2+L6NA=="],
|
||||||
|
|
||||||
|
"@inquirer/core": ["@inquirer/core@11.0.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.1", "@inquirer/figures": "^2.0.1", "@inquirer/type": "^4.0.1", "cli-width": "^4.1.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^9.0.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Tpf49h50e4KYffVUCXzkx4gWMafUi3aDQDwfVAAGBNnVcXiwJIj4m2bKlZ7Kgyf6wjt1eyXH1wDGXcAokm4Ssw=="],
|
||||||
|
|
||||||
|
"@inquirer/editor": ["@inquirer/editor@5.0.1", "", { "dependencies": { "@inquirer/core": "^11.0.1", "@inquirer/external-editor": "^2.0.1", "@inquirer/type": "^4.0.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-zDKobHI7Ry++4noiV9Z5VfYgSVpPZoMApviIuGwLOMciQaP+dGzCO+1fcwI441riklRiZg4yURWyEoX0Zy2zZw=="],
|
||||||
|
|
||||||
|
"@inquirer/expand": ["@inquirer/expand@5.0.1", "", { "dependencies": { "@inquirer/core": "^11.0.1", "@inquirer/type": "^4.0.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-TBrTpAB6uZNnGQHtSEkbvJZIQ3dXZOrwqQSO9uUbwct3G2LitwBCE5YZj98MbQ5nzihzs5pRjY1K9RRLH4WgoA=="],
|
||||||
|
|
||||||
|
"@inquirer/external-editor": ["@inquirer/external-editor@2.0.1", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BPYWJXCAK9w6R+pb2s3WyxUz9ts9SP/LDOUwA9fu7LeuyYgojz83i0DSRwezu736BgMwz14G63Xwj70hSzHohQ=="],
|
||||||
|
|
||||||
|
"@inquirer/figures": ["@inquirer/figures@2.0.1", "", {}, "sha512-KtMxyjLCuDFqAWHmCY9qMtsZ09HnjMsm8H3OvpSIpfhHdfw3/AiGWHNrfRwbyvHPtOJpumm8wGn5fkhtvkWRsg=="],
|
||||||
|
|
||||||
|
"@inquirer/input": ["@inquirer/input@5.0.1", "", { "dependencies": { "@inquirer/core": "^11.0.1", "@inquirer/type": "^4.0.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-cEhEUohCpE2BCuLKtFFZGp4Ief05SEcqeAOq9NxzN5ThOQP8Rl5N/Nt9VEDORK1bRb2Sk/zoOyQYfysPQwyQtA=="],
|
||||||
|
|
||||||
|
"@inquirer/number": ["@inquirer/number@4.0.1", "", { "dependencies": { "@inquirer/core": "^11.0.1", "@inquirer/type": "^4.0.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-4//zgBGHe8Q/FfCoUXZUrUHyK/q5dyqiwsePz3oSSPSmw1Ijo35ZkjaftnxroygcUlLYfXqm+0q08lnB5hd49A=="],
|
||||||
|
|
||||||
|
"@inquirer/password": ["@inquirer/password@5.0.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.1", "@inquirer/core": "^11.0.1", "@inquirer/type": "^4.0.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-UJudHpd7Ia30Q+x+ctYqI9Nh6SyEkaBscpa7J6Ts38oc1CNSws0I1hJEdxbQBlxQd65z5GEJPM4EtNf6tzfWaQ=="],
|
||||||
|
|
||||||
|
"@inquirer/prompts": ["@inquirer/prompts@8.0.1", "", { "dependencies": { "@inquirer/checkbox": "^5.0.1", "@inquirer/confirm": "^6.0.1", "@inquirer/editor": "^5.0.1", "@inquirer/expand": "^5.0.1", "@inquirer/input": "^5.0.1", "@inquirer/number": "^4.0.1", "@inquirer/password": "^5.0.1", "@inquirer/rawlist": "^5.0.1", "@inquirer/search": "^4.0.1", "@inquirer/select": "^5.0.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-MURRu/cyvLm9vchDDaVZ9u4p+ADnY0Mz3LQr0KTgihrrvuKZlqcWwlBC4lkOMvd0KKX4Wz7Ww9+uA7qEpQaqjg=="],
|
||||||
|
|
||||||
|
"@inquirer/rawlist": ["@inquirer/rawlist@5.0.1", "", { "dependencies": { "@inquirer/core": "^11.0.1", "@inquirer/type": "^4.0.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-vVfVHKUgH6rZmMlyd0jOuGZo0Fw1jfcOqZF96lMwlgavx7g0x7MICe316bV01EEoI+c68vMdbkTTawuw3O+Fgw=="],
|
||||||
|
|
||||||
|
"@inquirer/search": ["@inquirer/search@4.0.1", "", { "dependencies": { "@inquirer/core": "^11.0.1", "@inquirer/figures": "^2.0.1", "@inquirer/type": "^4.0.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-XwiaK5xBvr31STX6Ji8iS3HCRysBXfL/jUbTzufdWTS6LTGtvDQA50oVETt1BJgjKyQBp9vt0VU6AmU/AnOaGA=="],
|
||||||
|
|
||||||
|
"@inquirer/select": ["@inquirer/select@5.0.1", "", { "dependencies": { "@inquirer/ansi": "^2.0.1", "@inquirer/core": "^11.0.1", "@inquirer/figures": "^2.0.1", "@inquirer/type": "^4.0.1" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-gPByrgYoezGyKMq5KjV7Tuy1JU2ArIy6/sI8sprw0OpXope3VGQwP5FK1KD4eFFqEhKu470Dwe6/AyDPmGRA0Q=="],
|
||||||
|
|
||||||
|
"@inquirer/type": ["@inquirer/type@4.0.1", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-odO8YwoQAw/eVu/PSPsDDVPmqO77r/Mq7zcoF5VduVqIu2wSRWUgmYb5K9WH1no0SjLnOe8MDKtDL++z6mfo2g=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.22.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g=="],
|
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.22.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
|
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
|
||||||
|
|
@ -53,6 +87,10 @@
|
||||||
|
|
||||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||||
|
|
||||||
|
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
|
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
"body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="],
|
"body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
|
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
|
||||||
|
|
@ -63,6 +101,10 @@
|
||||||
|
|
||||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||||
|
|
||||||
|
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
|
||||||
|
|
||||||
|
"cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
|
||||||
|
|
||||||
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
||||||
|
|
||||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||||
|
|
@ -87,6 +129,8 @@
|
||||||
|
|
||||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
|
|
||||||
|
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||||
|
|
||||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||||
|
|
||||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||||
|
|
@ -119,6 +163,8 @@
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
|
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
|
||||||
|
|
||||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
|
|
||||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||||
|
|
@ -157,6 +203,8 @@
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="],
|
||||||
|
|
||||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||||
|
|
||||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||||
|
|
@ -207,8 +255,14 @@
|
||||||
|
|
||||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||||
|
|
||||||
|
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||||
|
|
||||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||||
|
|
||||||
|
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||||
|
|
||||||
|
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||||
|
|
||||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||||
|
|
||||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||||
|
|
@ -223,6 +277,8 @@
|
||||||
|
|
||||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||||
|
|
||||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
|
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "claudish",
|
"name": "claudish",
|
||||||
"version": "2.7.0",
|
"version": "2.8.0",
|
||||||
"description": "Run Claude Code with any OpenRouter model - CLI tool and MCP server",
|
"description": "Run Claude Code with any OpenRouter model - CLI tool and MCP server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|
@ -27,6 +27,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.19.6",
|
"@hono/node-server": "^1.19.6",
|
||||||
|
"@inquirer/prompts": "^8.0.1",
|
||||||
|
"@inquirer/search": "^4.0.1",
|
||||||
"@modelcontextprotocol/sdk": "^1.22.0",
|
"@modelcontextprotocol/sdk": "^1.22.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"hono": "^4.10.6",
|
"hono": "^4.10.6",
|
||||||
|
|
|
||||||
46
src/cli.ts
46
src/cli.ts
|
|
@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fuzzyScore } from "./utils.js";
|
import { fuzzyScore } from "./utils.js";
|
||||||
|
import { getProfile, getDefaultProfile, getModelMapping } from "./profile-config.js";
|
||||||
|
|
||||||
// Read version from package.json
|
// Read version from package.json
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
|
@ -92,7 +93,7 @@ export async function parseArgs(args: string[]): Promise<ClaudishConfig> {
|
||||||
} else if (arg === "--model-subagent") {
|
} else if (arg === "--model-subagent") {
|
||||||
const val = args[++i];
|
const val = args[++i];
|
||||||
if (val) config.modelSubagent = val;
|
if (val) config.modelSubagent = val;
|
||||||
} else if (arg === "--port" || arg === "-p") {
|
} else if (arg === "--port") {
|
||||||
const portArg = args[++i];
|
const portArg = args[++i];
|
||||||
if (!portArg) {
|
if (!portArg) {
|
||||||
console.error("--port requires a value");
|
console.error("--port requires a value");
|
||||||
|
|
@ -131,6 +132,13 @@ export async function parseArgs(args: string[]): Promise<ClaudishConfig> {
|
||||||
config.stdin = true;
|
config.stdin = true;
|
||||||
} else if (arg === "--free") {
|
} else if (arg === "--free") {
|
||||||
config.freeOnly = true;
|
config.freeOnly = true;
|
||||||
|
} else if (arg === "--profile" || arg === "-p") {
|
||||||
|
const profileArg = args[++i];
|
||||||
|
if (!profileArg) {
|
||||||
|
console.error("--profile requires a profile name");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
config.profile = profileArg;
|
||||||
} else if (arg === "--cost-tracker") {
|
} else if (arg === "--cost-tracker") {
|
||||||
// Enable cost tracking for this session
|
// Enable cost tracking for this session
|
||||||
config.costTracking = true;
|
config.costTracking = true;
|
||||||
|
|
@ -257,6 +265,26 @@ export async function parseArgs(args: string[]): Promise<ClaudishConfig> {
|
||||||
config.quiet = true; // JSON output mode is always quiet
|
config.quiet = true; // JSON output mode is always quiet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply profile model mappings (profile < CLI flags < env vars for override order)
|
||||||
|
// Profile provides defaults, CLI flags override, env vars override CLI
|
||||||
|
if (config.profile || !config.modelOpus || !config.modelSonnet || !config.modelHaiku || !config.modelSubagent) {
|
||||||
|
const profileModels = getModelMapping(config.profile);
|
||||||
|
|
||||||
|
// Apply profile models only if not set by CLI flags
|
||||||
|
if (!config.modelOpus && profileModels.opus) {
|
||||||
|
config.modelOpus = profileModels.opus;
|
||||||
|
}
|
||||||
|
if (!config.modelSonnet && profileModels.sonnet) {
|
||||||
|
config.modelSonnet = profileModels.sonnet;
|
||||||
|
}
|
||||||
|
if (!config.modelHaiku && profileModels.haiku) {
|
||||||
|
config.modelHaiku = profileModels.haiku;
|
||||||
|
}
|
||||||
|
if (!config.modelSubagent && profileModels.subagent) {
|
||||||
|
config.modelSubagent = profileModels.subagent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return config as ClaudishConfig;
|
return config as ClaudishConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -733,7 +761,8 @@ USAGE:
|
||||||
OPTIONS:
|
OPTIONS:
|
||||||
-i, --interactive Run in interactive mode (default when no prompt given)
|
-i, --interactive Run in interactive mode (default when no prompt given)
|
||||||
-m, --model <model> OpenRouter model to use (required for single-shot mode)
|
-m, --model <model> OpenRouter model to use (required for single-shot mode)
|
||||||
-p, --port <port> Proxy server port (default: random)
|
-p, --profile <name> Use named profile for model mapping (default: uses default profile)
|
||||||
|
--port <port> Proxy server port (default: random)
|
||||||
-d, --debug Enable debug logging to file (logs/claudish_*.log)
|
-d, --debug Enable debug logging to file (logs/claudish_*.log)
|
||||||
--log-level <level> Log verbosity: debug (full), info (truncated), minimal (labels only)
|
--log-level <level> Log verbosity: debug (full), info (truncated), minimal (labels only)
|
||||||
-q, --quiet Suppress [claudish] log messages (default in single-shot mode)
|
-q, --quiet Suppress [claudish] log messages (default in single-shot mode)
|
||||||
|
|
@ -757,6 +786,15 @@ OPTIONS:
|
||||||
--help-ai Show AI agent usage guide (file-based patterns, sub-agents)
|
--help-ai Show AI agent usage guide (file-based patterns, sub-agents)
|
||||||
--init Install Claudish skill in current project (.claude/skills/)
|
--init Install Claudish skill in current project (.claude/skills/)
|
||||||
|
|
||||||
|
PROFILE MANAGEMENT:
|
||||||
|
claudish init Setup wizard - create config and first profile
|
||||||
|
claudish profile list List all profiles
|
||||||
|
claudish profile add Add a new profile
|
||||||
|
claudish profile remove Remove a profile (interactive or claudish profile remove <name>)
|
||||||
|
claudish profile use Set default profile (interactive or claudish profile use <name>)
|
||||||
|
claudish profile show Show profile details (default profile or claudish profile show <name>)
|
||||||
|
claudish profile edit Edit a profile (interactive or claudish profile edit <name>)
|
||||||
|
|
||||||
MODEL MAPPING (per-role override):
|
MODEL MAPPING (per-role override):
|
||||||
--model-opus <model> Model for Opus role (planning, complex tasks)
|
--model-opus <model> Model for Opus role (planning, complex tasks)
|
||||||
--model-sonnet <model> Model for Sonnet role (default coding)
|
--model-sonnet <model> Model for Sonnet role (default coding)
|
||||||
|
|
@ -814,6 +852,10 @@ EXAMPLES:
|
||||||
# Per-role model mapping (use different models for different Claude Code roles)
|
# Per-role model mapping (use different models for different Claude Code roles)
|
||||||
claudish --model-opus openai/gpt-5 --model-sonnet x-ai/grok-code-fast-1 --model-haiku minimax/minimax-m2
|
claudish --model-opus openai/gpt-5 --model-sonnet x-ai/grok-code-fast-1 --model-haiku minimax/minimax-m2
|
||||||
|
|
||||||
|
# Use named profiles for pre-configured model mappings
|
||||||
|
claudish -p frontend "implement component"
|
||||||
|
claudish --profile debug "investigate error"
|
||||||
|
|
||||||
# Hybrid: Native Anthropic for Opus, OpenRouter for Sonnet/Haiku
|
# Hybrid: Native Anthropic for Opus, OpenRouter for Sonnet/Haiku
|
||||||
claudish --model-opus claude-3-opus-20240229 --model-sonnet x-ai/grok-code-fast-1
|
claudish --model-opus claude-3-opus-20240229 --model-sonnet x-ai/grok-code-fast-1
|
||||||
|
|
||||||
|
|
|
||||||
14
src/index.ts
14
src/index.ts
|
|
@ -7,9 +7,19 @@ config(); // Loads .env from current working directory
|
||||||
// Check for MCP mode before loading heavy dependencies
|
// Check for MCP mode before loading heavy dependencies
|
||||||
const isMcpMode = process.argv.includes("--mcp");
|
const isMcpMode = process.argv.includes("--mcp");
|
||||||
|
|
||||||
|
// Check for profile management commands
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const firstArg = args[0];
|
||||||
|
|
||||||
if (isMcpMode) {
|
if (isMcpMode) {
|
||||||
// MCP server mode - dynamic import to keep CLI fast
|
// MCP server mode - dynamic import to keep CLI fast
|
||||||
import("./mcp-server.js").then((mcp) => mcp.startMcpServer());
|
import("./mcp-server.js").then((mcp) => mcp.startMcpServer());
|
||||||
|
} else if (firstArg === "init") {
|
||||||
|
// Profile setup wizard
|
||||||
|
import("./profile-commands.js").then((pc) => pc.initCommand());
|
||||||
|
} else if (firstArg === "profile") {
|
||||||
|
// Profile management commands
|
||||||
|
import("./profile-commands.js").then((pc) => pc.profileCommand(args.slice(1)));
|
||||||
} else {
|
} else {
|
||||||
// CLI mode
|
// CLI mode
|
||||||
runCli();
|
runCli();
|
||||||
|
|
@ -22,7 +32,7 @@ async function runCli() {
|
||||||
const { checkClaudeInstalled, runClaudeWithProxy } = await import("./claude-runner.js");
|
const { checkClaudeInstalled, runClaudeWithProxy } = await import("./claude-runner.js");
|
||||||
const { parseArgs, getVersion } = await import("./cli.js");
|
const { parseArgs, getVersion } = await import("./cli.js");
|
||||||
const { DEFAULT_PORT_RANGE } = await import("./config.js");
|
const { DEFAULT_PORT_RANGE } = await import("./config.js");
|
||||||
const { selectModelInteractively, promptForApiKey } = await import("./simple-selector.js");
|
const { selectModel, promptForApiKey } = await import("./model-selector.js");
|
||||||
const { initLogger, getLogFilePath } = await import("./logger.js");
|
const { initLogger, getLogFilePath } = await import("./logger.js");
|
||||||
const { findAvailablePort } = await import("./port-manager.js");
|
const { findAvailablePort } = await import("./port-manager.js");
|
||||||
const { createProxyServer } = await import("./proxy-server.js");
|
const { createProxyServer } = await import("./proxy-server.js");
|
||||||
|
|
@ -80,7 +90,7 @@ async function runCli() {
|
||||||
|
|
||||||
// Show interactive model selector ONLY in interactive mode when model not specified
|
// Show interactive model selector ONLY in interactive mode when model not specified
|
||||||
if (cliConfig.interactive && !cliConfig.monitor && !cliConfig.model) {
|
if (cliConfig.interactive && !cliConfig.monitor && !cliConfig.model) {
|
||||||
cliConfig.model = await selectModelInteractively({ freeOnly: cliConfig.freeOnly });
|
cliConfig.model = await selectModel({ freeOnly: cliConfig.freeOnly });
|
||||||
console.log(""); // Empty line after selection
|
console.log(""); // Empty line after selection
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,493 @@
|
||||||
|
/**
|
||||||
|
* Model Selector with Fuzzy Search
|
||||||
|
*
|
||||||
|
* Uses @inquirer/search for fuzzy search model selection
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { search, select, input, confirm } from "@inquirer/prompts";
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { OpenRouterModel } from "./types.js";
|
||||||
|
import { getAvailableModels } from "./model-loader.js";
|
||||||
|
|
||||||
|
// Get __dirname equivalent in ESM
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// Cache paths
|
||||||
|
const ALL_MODELS_JSON_PATH = join(__dirname, "../all-models.json");
|
||||||
|
const RECOMMENDED_MODELS_JSON_PATH = join(__dirname, "../recommended-models.json");
|
||||||
|
const CACHE_MAX_AGE_DAYS = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model data structure
|
||||||
|
*/
|
||||||
|
export interface ModelInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
provider: string;
|
||||||
|
pricing?: {
|
||||||
|
input: string;
|
||||||
|
output: string;
|
||||||
|
average: string;
|
||||||
|
};
|
||||||
|
context?: string;
|
||||||
|
contextLength?: number;
|
||||||
|
supportsTools?: boolean;
|
||||||
|
supportsReasoning?: boolean;
|
||||||
|
supportsVision?: boolean;
|
||||||
|
isFree?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trusted providers for free models
|
||||||
|
*/
|
||||||
|
const TRUSTED_FREE_PROVIDERS = [
|
||||||
|
"google",
|
||||||
|
"openai",
|
||||||
|
"x-ai",
|
||||||
|
"deepseek",
|
||||||
|
"qwen",
|
||||||
|
"alibaba",
|
||||||
|
"meta-llama",
|
||||||
|
"microsoft",
|
||||||
|
"mistralai",
|
||||||
|
"nvidia",
|
||||||
|
"cohere",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load recommended models from JSON
|
||||||
|
*/
|
||||||
|
function loadRecommendedModels(): ModelInfo[] {
|
||||||
|
if (existsSync(RECOMMENDED_MODELS_JSON_PATH)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(RECOMMENDED_MODELS_JSON_PATH, "utf-8");
|
||||||
|
const data = JSON.parse(content);
|
||||||
|
return data.models || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all models from OpenRouter API
|
||||||
|
*/
|
||||||
|
async function fetchAllModels(forceUpdate = false): Promise<any[]> {
|
||||||
|
// Check cache
|
||||||
|
if (!forceUpdate && existsSync(ALL_MODELS_JSON_PATH)) {
|
||||||
|
try {
|
||||||
|
const cacheData = JSON.parse(readFileSync(ALL_MODELS_JSON_PATH, "utf-8"));
|
||||||
|
const lastUpdated = new Date(cacheData.lastUpdated);
|
||||||
|
const now = new Date();
|
||||||
|
const ageInDays =
|
||||||
|
(now.getTime() - lastUpdated.getTime()) / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
if (ageInDays <= CACHE_MAX_AGE_DAYS) {
|
||||||
|
return cacheData.models;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Cache error, will fetch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from API
|
||||||
|
console.log("Fetching models from OpenRouter...");
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://openrouter.ai/api/v1/models");
|
||||||
|
if (!response.ok) throw new Error(`API returned ${response.status}`);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const models = data.data;
|
||||||
|
|
||||||
|
// Cache result
|
||||||
|
writeFileSync(
|
||||||
|
ALL_MODELS_JSON_PATH,
|
||||||
|
JSON.stringify({
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
models,
|
||||||
|
}),
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Cached ${models.length} models`);
|
||||||
|
return models;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch models: ${error}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert raw OpenRouter model to ModelInfo
|
||||||
|
*/
|
||||||
|
function toModelInfo(model: any): ModelInfo {
|
||||||
|
const provider = model.id.split("/")[0];
|
||||||
|
const contextLen =
|
||||||
|
model.context_length || model.top_provider?.context_length || 0;
|
||||||
|
const promptPrice = parseFloat(model.pricing?.prompt || "0");
|
||||||
|
const completionPrice = parseFloat(model.pricing?.completion || "0");
|
||||||
|
const isFree = promptPrice === 0 && completionPrice === 0;
|
||||||
|
|
||||||
|
// Format pricing
|
||||||
|
let pricingStr = "N/A";
|
||||||
|
if (isFree) {
|
||||||
|
pricingStr = "FREE";
|
||||||
|
} else if (model.pricing) {
|
||||||
|
const avgPrice = (promptPrice + completionPrice) / 2;
|
||||||
|
if (avgPrice < 0.001) {
|
||||||
|
pricingStr = `$${(avgPrice * 1000000).toFixed(2)}/1M`;
|
||||||
|
} else {
|
||||||
|
pricingStr = `$${avgPrice.toFixed(4)}/1K`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name || model.id,
|
||||||
|
description: model.description || "",
|
||||||
|
provider: provider.charAt(0).toUpperCase() + provider.slice(1),
|
||||||
|
pricing: {
|
||||||
|
input: model.pricing?.prompt || "N/A",
|
||||||
|
output: model.pricing?.completion || "N/A",
|
||||||
|
average: pricingStr,
|
||||||
|
},
|
||||||
|
context: contextLen > 0 ? `${Math.round(contextLen / 1000)}K` : "N/A",
|
||||||
|
contextLength: contextLen,
|
||||||
|
supportsTools: (model.supported_parameters || []).includes("tools"),
|
||||||
|
supportsReasoning: (model.supported_parameters || []).includes("reasoning"),
|
||||||
|
supportsVision: (model.architecture?.input_modalities || []).includes(
|
||||||
|
"image"
|
||||||
|
),
|
||||||
|
isFree,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get free models from cache/API
|
||||||
|
*/
|
||||||
|
async function getFreeModels(): Promise<ModelInfo[]> {
|
||||||
|
const allModels = await fetchAllModels();
|
||||||
|
|
||||||
|
// Filter for FREE models from TRUSTED providers
|
||||||
|
const freeModels = allModels.filter((model) => {
|
||||||
|
const promptPrice = parseFloat(model.pricing?.prompt || "0");
|
||||||
|
const completionPrice = parseFloat(model.pricing?.completion || "0");
|
||||||
|
const isFree = promptPrice === 0 && completionPrice === 0;
|
||||||
|
|
||||||
|
if (!isFree) return false;
|
||||||
|
|
||||||
|
const provider = model.id.split("/")[0].toLowerCase();
|
||||||
|
return TRUSTED_FREE_PROVIDERS.includes(provider);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by context window (largest first)
|
||||||
|
freeModels.sort((a, b) => {
|
||||||
|
const contextA = a.context_length || a.top_provider?.context_length || 0;
|
||||||
|
const contextB = b.context_length || b.top_provider?.context_length || 0;
|
||||||
|
return contextB - contextA;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dedupe: prefer non-:free variant
|
||||||
|
const seenBase = new Set<string>();
|
||||||
|
const dedupedModels = freeModels.filter((model) => {
|
||||||
|
const baseId = model.id.replace(/:free$/, "");
|
||||||
|
if (seenBase.has(baseId)) return false;
|
||||||
|
seenBase.add(baseId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return dedupedModels.slice(0, 20).map(toModelInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all models for search
|
||||||
|
*/
|
||||||
|
async function getAllModelsForSearch(): Promise<ModelInfo[]> {
|
||||||
|
const allModels = await fetchAllModels();
|
||||||
|
return allModels.map(toModelInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format model for display in selector
|
||||||
|
*/
|
||||||
|
function formatModelChoice(model: ModelInfo): string {
|
||||||
|
const caps = [
|
||||||
|
model.supportsTools ? "T" : "",
|
||||||
|
model.supportsReasoning ? "R" : "",
|
||||||
|
model.supportsVision ? "V" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const capsStr = caps ? ` [${caps}]` : "";
|
||||||
|
const priceStr = model.pricing?.average || "N/A";
|
||||||
|
const ctxStr = model.context || "N/A";
|
||||||
|
|
||||||
|
return `${model.id} (${model.provider}, ${priceStr}, ${ctxStr}${capsStr})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuzzy match score
|
||||||
|
*/
|
||||||
|
function fuzzyMatch(text: string, query: string): number {
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if (lowerText === lowerQuery) return 1;
|
||||||
|
|
||||||
|
// Contains match
|
||||||
|
if (lowerText.includes(lowerQuery)) return 0.8;
|
||||||
|
|
||||||
|
// Fuzzy character match
|
||||||
|
let queryIdx = 0;
|
||||||
|
let score = 0;
|
||||||
|
for (let i = 0; i < lowerText.length && queryIdx < lowerQuery.length; i++) {
|
||||||
|
if (lowerText[i] === lowerQuery[queryIdx]) {
|
||||||
|
score++;
|
||||||
|
queryIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryIdx === lowerQuery.length ? score / lowerQuery.length * 0.6 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelSelectorOptions {
|
||||||
|
freeOnly?: boolean;
|
||||||
|
recommended?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a model interactively with fuzzy search
|
||||||
|
*/
|
||||||
|
export async function selectModel(
|
||||||
|
options: ModelSelectorOptions = {}
|
||||||
|
): Promise<string> {
|
||||||
|
const { freeOnly = false, recommended = true, message } = options;
|
||||||
|
|
||||||
|
let models: ModelInfo[];
|
||||||
|
|
||||||
|
if (freeOnly) {
|
||||||
|
models = await getFreeModels();
|
||||||
|
if (models.length === 0) {
|
||||||
|
throw new Error("No free models available");
|
||||||
|
}
|
||||||
|
} else if (recommended) {
|
||||||
|
// Load recommended models first
|
||||||
|
const recommendedModels = loadRecommendedModels();
|
||||||
|
if (recommendedModels.length > 0) {
|
||||||
|
models = recommendedModels;
|
||||||
|
} else {
|
||||||
|
// Fall back to fetching
|
||||||
|
const allModels = await getAllModelsForSearch();
|
||||||
|
models = allModels.slice(0, 20);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
models = await getAllModelsForSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptMessage = message || (freeOnly
|
||||||
|
? "Select a FREE model (type to search):"
|
||||||
|
: "Select a model (type to search):");
|
||||||
|
|
||||||
|
const selected = await search<string>({
|
||||||
|
message: promptMessage,
|
||||||
|
source: async (term) => {
|
||||||
|
if (!term) {
|
||||||
|
// Show all/top models when no search term
|
||||||
|
return models.slice(0, 15).map((m) => ({
|
||||||
|
name: formatModelChoice(m),
|
||||||
|
value: m.id,
|
||||||
|
description: m.description?.slice(0, 80),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuzzy search
|
||||||
|
const results = models
|
||||||
|
.map((m) => ({
|
||||||
|
model: m,
|
||||||
|
score: Math.max(
|
||||||
|
fuzzyMatch(m.id, term),
|
||||||
|
fuzzyMatch(m.name, term),
|
||||||
|
fuzzyMatch(m.provider, term) * 0.5
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter((r) => r.score > 0.1)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 15);
|
||||||
|
|
||||||
|
return results.map((r) => ({
|
||||||
|
name: formatModelChoice(r.model),
|
||||||
|
value: r.model.id,
|
||||||
|
description: r.model.description?.slice(0, 80),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select multiple models for profile setup
|
||||||
|
*/
|
||||||
|
export async function selectModelsForProfile(): Promise<{
|
||||||
|
opus?: string;
|
||||||
|
sonnet?: string;
|
||||||
|
haiku?: string;
|
||||||
|
subagent?: string;
|
||||||
|
}> {
|
||||||
|
const allModels = await getAllModelsForSearch();
|
||||||
|
|
||||||
|
console.log("\nConfigure models for each Claude tier:\n");
|
||||||
|
|
||||||
|
// Helper to select a model for a tier
|
||||||
|
const selectForTier = async (
|
||||||
|
tier: string,
|
||||||
|
description: string
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
const useCustom = await confirm({
|
||||||
|
message: `Configure ${tier} model? (${description})`,
|
||||||
|
default: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!useCustom) return undefined;
|
||||||
|
|
||||||
|
return search<string>({
|
||||||
|
message: `Select model for ${tier}:`,
|
||||||
|
source: async (term) => {
|
||||||
|
let filtered = allModels;
|
||||||
|
|
||||||
|
if (term) {
|
||||||
|
filtered = allModels
|
||||||
|
.map((m) => ({
|
||||||
|
model: m,
|
||||||
|
score: Math.max(
|
||||||
|
fuzzyMatch(m.id, term),
|
||||||
|
fuzzyMatch(m.name, term),
|
||||||
|
fuzzyMatch(m.provider, term) * 0.5
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter((r) => r.score > 0.1)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 15)
|
||||||
|
.map((r) => r.model);
|
||||||
|
} else {
|
||||||
|
filtered = filtered.slice(0, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.map((m) => ({
|
||||||
|
name: formatModelChoice(m),
|
||||||
|
value: m.id,
|
||||||
|
description: m.description?.slice(0, 80),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const opus = await selectForTier(
|
||||||
|
"Opus",
|
||||||
|
"Most capable, used for complex reasoning"
|
||||||
|
);
|
||||||
|
const sonnet = await selectForTier(
|
||||||
|
"Sonnet",
|
||||||
|
"Balanced, used for general tasks"
|
||||||
|
);
|
||||||
|
const haiku = await selectForTier("Haiku", "Fast & cheap, used for simple tasks");
|
||||||
|
const subagent = await selectForTier(
|
||||||
|
"Subagent",
|
||||||
|
"Used for spawned sub-agents"
|
||||||
|
);
|
||||||
|
|
||||||
|
return { opus, sonnet, haiku, subagent };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt for API key
|
||||||
|
*/
|
||||||
|
export async function promptForApiKey(): Promise<string> {
|
||||||
|
console.log("\nOpenRouter API Key Required");
|
||||||
|
console.log("Get your free API key from: https://openrouter.ai/keys\n");
|
||||||
|
|
||||||
|
const apiKey = await input({
|
||||||
|
message: "Enter your OpenRouter API key:",
|
||||||
|
validate: (value) => {
|
||||||
|
if (!value.trim()) {
|
||||||
|
return "API key cannot be empty";
|
||||||
|
}
|
||||||
|
if (!value.startsWith("sk-or-")) {
|
||||||
|
return 'API key should start with "sk-or-"';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt for profile name
|
||||||
|
*/
|
||||||
|
export async function promptForProfileName(
|
||||||
|
existing: string[] = []
|
||||||
|
): Promise<string> {
|
||||||
|
const name = await input({
|
||||||
|
message: "Enter profile name:",
|
||||||
|
validate: (value) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "Profile name cannot be empty";
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9-_]+$/i.test(trimmed)) {
|
||||||
|
return "Profile name can only contain letters, numbers, hyphens, and underscores";
|
||||||
|
}
|
||||||
|
if (existing.includes(trimmed)) {
|
||||||
|
return `Profile "${trimmed}" already exists`;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return name.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt for profile description
|
||||||
|
*/
|
||||||
|
export async function promptForProfileDescription(): Promise<string> {
|
||||||
|
const description = await input({
|
||||||
|
message: "Enter profile description (optional):",
|
||||||
|
});
|
||||||
|
|
||||||
|
return description.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select from existing profiles
|
||||||
|
*/
|
||||||
|
export async function selectProfile(
|
||||||
|
profiles: { name: string; description?: string; isDefault?: boolean }[]
|
||||||
|
): Promise<string> {
|
||||||
|
const selected = await select({
|
||||||
|
message: "Select a profile:",
|
||||||
|
choices: profiles.map((p) => ({
|
||||||
|
name: p.isDefault ? `${p.name} (default)` : p.name,
|
||||||
|
value: p.name,
|
||||||
|
description: p.description,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm action
|
||||||
|
*/
|
||||||
|
export async function confirmAction(message: string): Promise<boolean> {
|
||||||
|
return confirm({ message, default: false });
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,435 @@
|
||||||
|
/**
|
||||||
|
* Profile Management Commands
|
||||||
|
*
|
||||||
|
* Implements CLI commands for managing Claudish profiles:
|
||||||
|
* - claudish init: Initial setup wizard
|
||||||
|
* - claudish profile list: List all profiles
|
||||||
|
* - claudish profile add: Add a new profile
|
||||||
|
* - claudish profile remove <name>: Remove a profile
|
||||||
|
* - claudish profile use <name>: Set default profile
|
||||||
|
* - claudish profile show [name]: Show profile details
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
loadConfig,
|
||||||
|
saveConfig,
|
||||||
|
getProfile,
|
||||||
|
getDefaultProfile,
|
||||||
|
getProfileNames,
|
||||||
|
setProfile,
|
||||||
|
deleteProfile,
|
||||||
|
setDefaultProfile,
|
||||||
|
createProfile,
|
||||||
|
listProfiles,
|
||||||
|
configExists,
|
||||||
|
getConfigPath,
|
||||||
|
type Profile,
|
||||||
|
type ModelMapping,
|
||||||
|
} from "./profile-config.js";
|
||||||
|
import {
|
||||||
|
selectModel,
|
||||||
|
selectModelsForProfile,
|
||||||
|
promptForProfileName,
|
||||||
|
promptForProfileDescription,
|
||||||
|
selectProfile,
|
||||||
|
confirmAction,
|
||||||
|
} from "./model-selector.js";
|
||||||
|
import { select, confirm } from "@inquirer/prompts";
|
||||||
|
|
||||||
|
// ANSI colors
|
||||||
|
const RESET = "\x1b[0m";
|
||||||
|
const BOLD = "\x1b[1m";
|
||||||
|
const DIM = "\x1b[2m";
|
||||||
|
const GREEN = "\x1b[32m";
|
||||||
|
const YELLOW = "\x1b[33m";
|
||||||
|
const CYAN = "\x1b[36m";
|
||||||
|
const MAGENTA = "\x1b[35m";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial setup wizard
|
||||||
|
* Creates the first profile and config file
|
||||||
|
*/
|
||||||
|
export async function initCommand(): Promise<void> {
|
||||||
|
console.log(`\n${BOLD}${CYAN}Claudish Setup Wizard${RESET}\n`);
|
||||||
|
|
||||||
|
if (configExists()) {
|
||||||
|
const overwrite = await confirm({
|
||||||
|
message: "Configuration already exists. Do you want to reconfigure?",
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!overwrite) {
|
||||||
|
console.log("Setup cancelled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${DIM}This wizard will help you set up Claudish with your preferred models.${RESET}\n`);
|
||||||
|
|
||||||
|
// Create default profile
|
||||||
|
console.log(`${BOLD}Step 1: Create your default profile${RESET}\n`);
|
||||||
|
|
||||||
|
const profileName = await promptForProfileName([]);
|
||||||
|
const description = await promptForProfileDescription();
|
||||||
|
|
||||||
|
console.log(`\n${BOLD}Step 2: Select models for each Claude tier${RESET}`);
|
||||||
|
console.log(`${DIM}These models will be used when Claude Code requests specific model types.${RESET}\n`);
|
||||||
|
|
||||||
|
const models = await selectModelsForProfile();
|
||||||
|
|
||||||
|
// Create and save profile
|
||||||
|
const profile = createProfile(profileName, models, description);
|
||||||
|
|
||||||
|
// Set as default
|
||||||
|
setDefaultProfile(profileName);
|
||||||
|
|
||||||
|
console.log(`\n${GREEN}✓${RESET} Configuration saved to: ${CYAN}${getConfigPath()}${RESET}`);
|
||||||
|
console.log(`\n${BOLD}Profile created:${RESET}`);
|
||||||
|
printProfile(profile, true);
|
||||||
|
|
||||||
|
console.log(`\n${BOLD}Usage:${RESET}`);
|
||||||
|
console.log(` ${CYAN}claudish${RESET} # Use default profile`);
|
||||||
|
console.log(` ${CYAN}claudish -p ${profileName}${RESET} # Use this profile explicitly`);
|
||||||
|
console.log(` ${CYAN}claudish profile add${RESET} # Add another profile`);
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all profiles
|
||||||
|
*/
|
||||||
|
export async function profileListCommand(): Promise<void> {
|
||||||
|
const profiles = listProfiles();
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
console.log("No profiles found. Run 'claudish init' to create one.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${BOLD}Claudish Profiles${RESET}\n`);
|
||||||
|
console.log(`${DIM}Config: ${getConfigPath()}${RESET}\n`);
|
||||||
|
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const isDefault = profile.name === config.defaultProfile;
|
||||||
|
printProfile(profile, isDefault);
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new profile
|
||||||
|
*/
|
||||||
|
export async function profileAddCommand(): Promise<void> {
|
||||||
|
console.log(`\n${BOLD}${CYAN}Add New Profile${RESET}\n`);
|
||||||
|
|
||||||
|
const existingNames = getProfileNames();
|
||||||
|
const name = await promptForProfileName(existingNames);
|
||||||
|
const description = await promptForProfileDescription();
|
||||||
|
|
||||||
|
console.log(`\n${BOLD}Select models for this profile:${RESET}\n`);
|
||||||
|
const models = await selectModelsForProfile();
|
||||||
|
|
||||||
|
const profile = createProfile(name, models, description);
|
||||||
|
|
||||||
|
console.log(`\n${GREEN}✓${RESET} Profile "${name}" created.`);
|
||||||
|
printProfile(profile, false);
|
||||||
|
|
||||||
|
const setAsDefault = await confirm({
|
||||||
|
message: "Set this profile as default?",
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (setAsDefault) {
|
||||||
|
setDefaultProfile(name);
|
||||||
|
console.log(`${GREEN}✓${RESET} "${name}" is now the default profile.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a profile
|
||||||
|
*/
|
||||||
|
export async function profileRemoveCommand(name?: string): Promise<void> {
|
||||||
|
const profiles = getProfileNames();
|
||||||
|
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
console.log("No profiles to remove.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profiles.length === 1) {
|
||||||
|
console.log("Cannot remove the last profile. Create another one first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let profileName = name;
|
||||||
|
|
||||||
|
if (!profileName) {
|
||||||
|
const profileList = listProfiles();
|
||||||
|
profileName = await selectProfile(
|
||||||
|
profileList.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
isDefault: p.name === loadConfig().defaultProfile,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = getProfile(profileName);
|
||||||
|
if (!profile) {
|
||||||
|
console.log(`Profile "${profileName}" not found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await confirmAction(
|
||||||
|
`Are you sure you want to delete profile "${profileName}"?`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
console.log("Cancelled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
deleteProfile(profileName);
|
||||||
|
console.log(`${GREEN}✓${RESET} Profile "${profileName}" deleted.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set default profile
|
||||||
|
*/
|
||||||
|
export async function profileUseCommand(name?: string): Promise<void> {
|
||||||
|
const profiles = getProfileNames();
|
||||||
|
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
console.log("No profiles found. Run 'claudish init' to create one.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let profileName = name;
|
||||||
|
|
||||||
|
if (!profileName) {
|
||||||
|
const profileList = listProfiles();
|
||||||
|
profileName = await selectProfile(
|
||||||
|
profileList.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
isDefault: p.name === loadConfig().defaultProfile,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = getProfile(profileName);
|
||||||
|
if (!profile) {
|
||||||
|
console.log(`Profile "${profileName}" not found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDefaultProfile(profileName);
|
||||||
|
console.log(`${GREEN}✓${RESET} "${profileName}" is now the default profile.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show profile details
|
||||||
|
*/
|
||||||
|
export async function profileShowCommand(name?: string): Promise<void> {
|
||||||
|
let profileName = name;
|
||||||
|
|
||||||
|
if (!profileName) {
|
||||||
|
const config = loadConfig();
|
||||||
|
profileName = config.defaultProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = getProfile(profileName);
|
||||||
|
if (!profile) {
|
||||||
|
console.log(`Profile "${profileName}" not found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const isDefault = profileName === config.defaultProfile;
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
printProfile(profile, isDefault, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit an existing profile
|
||||||
|
*/
|
||||||
|
export async function profileEditCommand(name?: string): Promise<void> {
|
||||||
|
const profiles = getProfileNames();
|
||||||
|
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
console.log("No profiles found. Run 'claudish init' to create one.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let profileName = name;
|
||||||
|
|
||||||
|
if (!profileName) {
|
||||||
|
const profileList = listProfiles();
|
||||||
|
profileName = await selectProfile(
|
||||||
|
profileList.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
isDefault: p.name === loadConfig().defaultProfile,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = getProfile(profileName);
|
||||||
|
if (!profile) {
|
||||||
|
console.log(`Profile "${profileName}" not found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${BOLD}Editing profile: ${profileName}${RESET}\n`);
|
||||||
|
console.log(`${DIM}Current models:${RESET}`);
|
||||||
|
printModelMapping(profile.models);
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
const whatToEdit = await select({
|
||||||
|
message: "What do you want to edit?",
|
||||||
|
choices: [
|
||||||
|
{ name: "All models", value: "all" },
|
||||||
|
{ name: "Opus model only", value: "opus" },
|
||||||
|
{ name: "Sonnet model only", value: "sonnet" },
|
||||||
|
{ name: "Haiku model only", value: "haiku" },
|
||||||
|
{ name: "Subagent model only", value: "subagent" },
|
||||||
|
{ name: "Description", value: "description" },
|
||||||
|
{ name: "Cancel", value: "cancel" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (whatToEdit === "cancel") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (whatToEdit === "description") {
|
||||||
|
const newDescription = await promptForProfileDescription();
|
||||||
|
profile.description = newDescription;
|
||||||
|
setProfile(profile);
|
||||||
|
console.log(`${GREEN}✓${RESET} Description updated.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (whatToEdit === "all") {
|
||||||
|
const models = await selectModelsForProfile();
|
||||||
|
profile.models = { ...profile.models, ...models };
|
||||||
|
setProfile(profile);
|
||||||
|
console.log(`${GREEN}✓${RESET} All models updated.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit single model
|
||||||
|
const tier = whatToEdit as keyof ModelMapping;
|
||||||
|
const tierName = tier.charAt(0).toUpperCase() + tier.slice(1);
|
||||||
|
|
||||||
|
const newModel = await selectModel({
|
||||||
|
message: `Select new model for ${tierName}:`,
|
||||||
|
});
|
||||||
|
|
||||||
|
profile.models[tier] = newModel;
|
||||||
|
setProfile(profile);
|
||||||
|
console.log(`${GREEN}✓${RESET} ${tierName} model updated to: ${newModel}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print a profile
|
||||||
|
*/
|
||||||
|
function printProfile(
|
||||||
|
profile: Profile,
|
||||||
|
isDefault: boolean,
|
||||||
|
verbose = false
|
||||||
|
): void {
|
||||||
|
const defaultBadge = isDefault ? ` ${YELLOW}(default)${RESET}` : "";
|
||||||
|
console.log(`${BOLD}${profile.name}${RESET}${defaultBadge}`);
|
||||||
|
|
||||||
|
if (profile.description) {
|
||||||
|
console.log(` ${DIM}${profile.description}${RESET}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
printModelMapping(profile.models);
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(` ${DIM}Created: ${profile.createdAt}${RESET}`);
|
||||||
|
console.log(` ${DIM}Updated: ${profile.updatedAt}${RESET}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print model mapping
|
||||||
|
*/
|
||||||
|
function printModelMapping(models: ModelMapping): void {
|
||||||
|
console.log(` ${CYAN}opus${RESET}: ${models.opus || DIM + "not set" + RESET}`);
|
||||||
|
console.log(` ${CYAN}sonnet${RESET}: ${models.sonnet || DIM + "not set" + RESET}`);
|
||||||
|
console.log(` ${CYAN}haiku${RESET}: ${models.haiku || DIM + "not set" + RESET}`);
|
||||||
|
if (models.subagent) {
|
||||||
|
console.log(` ${CYAN}subagent${RESET}: ${models.subagent}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main profile command router
|
||||||
|
*/
|
||||||
|
export async function profileCommand(args: string[]): Promise<void> {
|
||||||
|
const subcommand = args[0];
|
||||||
|
const name = args[1];
|
||||||
|
|
||||||
|
switch (subcommand) {
|
||||||
|
case "list":
|
||||||
|
case "ls":
|
||||||
|
await profileListCommand();
|
||||||
|
break;
|
||||||
|
case "add":
|
||||||
|
case "new":
|
||||||
|
case "create":
|
||||||
|
await profileAddCommand();
|
||||||
|
break;
|
||||||
|
case "remove":
|
||||||
|
case "rm":
|
||||||
|
case "delete":
|
||||||
|
await profileRemoveCommand(name);
|
||||||
|
break;
|
||||||
|
case "use":
|
||||||
|
case "default":
|
||||||
|
case "set":
|
||||||
|
await profileUseCommand(name);
|
||||||
|
break;
|
||||||
|
case "show":
|
||||||
|
case "view":
|
||||||
|
await profileShowCommand(name);
|
||||||
|
break;
|
||||||
|
case "edit":
|
||||||
|
await profileEditCommand(name);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// No subcommand - show help
|
||||||
|
printProfileHelp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print profile command help
|
||||||
|
*/
|
||||||
|
function printProfileHelp(): void {
|
||||||
|
console.log(`
|
||||||
|
${BOLD}Usage:${RESET} claudish profile <command> [options]
|
||||||
|
|
||||||
|
${BOLD}Commands:${RESET}
|
||||||
|
${CYAN}list${RESET}, ${CYAN}ls${RESET} List all profiles
|
||||||
|
${CYAN}add${RESET}, ${CYAN}new${RESET} Add a new profile
|
||||||
|
${CYAN}remove${RESET} ${DIM}[name]${RESET} Remove a profile
|
||||||
|
${CYAN}use${RESET} ${DIM}[name]${RESET} Set default profile
|
||||||
|
${CYAN}show${RESET} ${DIM}[name]${RESET} Show profile details
|
||||||
|
${CYAN}edit${RESET} ${DIM}[name]${RESET} Edit a profile
|
||||||
|
|
||||||
|
${BOLD}Examples:${RESET}
|
||||||
|
claudish profile list
|
||||||
|
claudish profile add
|
||||||
|
claudish profile use frontend
|
||||||
|
claudish profile remove debug
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
/**
|
||||||
|
* Claudish Profile Configuration
|
||||||
|
*
|
||||||
|
* Manages user profiles for model mapping.
|
||||||
|
* Config file location: ~/.claudish/config.json
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
// Config directory and file paths
|
||||||
|
const CONFIG_DIR = join(homedir(), ".claudish");
|
||||||
|
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model mapping for a profile
|
||||||
|
* Maps Claude model types to OpenRouter model IDs
|
||||||
|
*/
|
||||||
|
export interface ModelMapping {
|
||||||
|
opus?: string; // Model for opus (claude-opus-4-*)
|
||||||
|
sonnet?: string; // Model for sonnet (claude-sonnet-4-*)
|
||||||
|
haiku?: string; // Model for haiku (claude-haiku-*)
|
||||||
|
subagent?: string; // Model for subagents (CLAUDE_CODE_SUBAGENT_MODEL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A named profile with model mappings
|
||||||
|
*/
|
||||||
|
export interface Profile {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
models: ModelMapping;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root configuration structure
|
||||||
|
*/
|
||||||
|
export interface ClaudishProfileConfig {
|
||||||
|
version: string;
|
||||||
|
defaultProfile: string;
|
||||||
|
profiles: Record<string, Profile>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default configuration
|
||||||
|
*/
|
||||||
|
const DEFAULT_CONFIG: ClaudishProfileConfig = {
|
||||||
|
version: "1.0.0",
|
||||||
|
defaultProfile: "default",
|
||||||
|
profiles: {
|
||||||
|
default: {
|
||||||
|
name: "default",
|
||||||
|
description: "Default profile - balanced performance and cost",
|
||||||
|
models: {
|
||||||
|
opus: "x-ai/grok-3-beta",
|
||||||
|
sonnet: "x-ai/grok-code-fast-1",
|
||||||
|
haiku: "google/gemini-2.5-flash",
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure config directory exists
|
||||||
|
*/
|
||||||
|
function ensureConfigDir(): void {
|
||||||
|
if (!existsSync(CONFIG_DIR)) {
|
||||||
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load configuration from file
|
||||||
|
* Returns default config if file doesn't exist
|
||||||
|
*/
|
||||||
|
export function loadConfig(): ClaudishProfileConfig {
|
||||||
|
ensureConfigDir();
|
||||||
|
|
||||||
|
if (!existsSync(CONFIG_FILE)) {
|
||||||
|
return { ...DEFAULT_CONFIG };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(CONFIG_FILE, "utf-8");
|
||||||
|
const config = JSON.parse(content) as ClaudishProfileConfig;
|
||||||
|
|
||||||
|
// Validate and merge with defaults
|
||||||
|
return {
|
||||||
|
version: config.version || DEFAULT_CONFIG.version,
|
||||||
|
defaultProfile: config.defaultProfile || DEFAULT_CONFIG.defaultProfile,
|
||||||
|
profiles: config.profiles || DEFAULT_CONFIG.profiles,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Warning: Failed to load config, using defaults: ${error}`);
|
||||||
|
return { ...DEFAULT_CONFIG };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save configuration to file
|
||||||
|
*/
|
||||||
|
export function saveConfig(config: ClaudishProfileConfig): void {
|
||||||
|
ensureConfigDir();
|
||||||
|
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if config file exists
|
||||||
|
*/
|
||||||
|
export function configExists(): boolean {
|
||||||
|
return existsSync(CONFIG_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get config file path
|
||||||
|
*/
|
||||||
|
export function getConfigPath(): string {
|
||||||
|
return CONFIG_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a profile by name
|
||||||
|
* Returns undefined if profile doesn't exist
|
||||||
|
*/
|
||||||
|
export function getProfile(name: string): Profile | undefined {
|
||||||
|
const config = loadConfig();
|
||||||
|
return config.profiles[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default profile
|
||||||
|
*/
|
||||||
|
export function getDefaultProfile(): Profile {
|
||||||
|
const config = loadConfig();
|
||||||
|
const profile = config.profiles[config.defaultProfile];
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
// Fallback to first profile or create default
|
||||||
|
const firstProfile = Object.values(config.profiles)[0];
|
||||||
|
if (firstProfile) {
|
||||||
|
return firstProfile;
|
||||||
|
}
|
||||||
|
return DEFAULT_CONFIG.profiles.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all profile names
|
||||||
|
*/
|
||||||
|
export function getProfileNames(): string[] {
|
||||||
|
const config = loadConfig();
|
||||||
|
return Object.keys(config.profiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add or update a profile
|
||||||
|
*/
|
||||||
|
export function setProfile(profile: Profile): void {
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
const existingProfile = config.profiles[profile.name];
|
||||||
|
if (existingProfile) {
|
||||||
|
profile.createdAt = existingProfile.createdAt;
|
||||||
|
} else {
|
||||||
|
profile.createdAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
profile.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
config.profiles[profile.name] = profile;
|
||||||
|
saveConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a profile
|
||||||
|
* Cannot delete the last profile or the default profile if it's the only one
|
||||||
|
*/
|
||||||
|
export function deleteProfile(name: string): boolean {
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
if (!config.profiles[name]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileCount = Object.keys(config.profiles).length;
|
||||||
|
if (profileCount <= 1) {
|
||||||
|
throw new Error("Cannot delete the last profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
delete config.profiles[name];
|
||||||
|
|
||||||
|
// If we deleted the default profile, set a new default
|
||||||
|
if (config.defaultProfile === name) {
|
||||||
|
config.defaultProfile = Object.keys(config.profiles)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
saveConfig(config);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the default profile
|
||||||
|
*/
|
||||||
|
export function setDefaultProfile(name: string): void {
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
if (!config.profiles[name]) {
|
||||||
|
throw new Error(`Profile "${name}" does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
config.defaultProfile = name;
|
||||||
|
saveConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get model mapping from a profile
|
||||||
|
* Falls back to environment variables if profile doesn't have a mapping
|
||||||
|
*/
|
||||||
|
export function getModelMapping(profileName?: string): ModelMapping {
|
||||||
|
const profile = profileName ? getProfile(profileName) : getDefaultProfile();
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile.models;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new profile with the given models
|
||||||
|
*/
|
||||||
|
export function createProfile(
|
||||||
|
name: string,
|
||||||
|
models: ModelMapping,
|
||||||
|
description?: string
|
||||||
|
): Profile {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const profile: Profile = {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
models,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
setProfile(profile);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all profiles with their details
|
||||||
|
*/
|
||||||
|
export function listProfiles(): Profile[] {
|
||||||
|
const config = loadConfig();
|
||||||
|
return Object.values(config.profiles).map((profile) => ({
|
||||||
|
...profile,
|
||||||
|
isDefault: profile.name === config.defaultProfile,
|
||||||
|
})) as (Profile & { isDefault?: boolean })[];
|
||||||
|
}
|
||||||
|
|
@ -1,438 +0,0 @@
|
||||||
import { createInterface } from "readline";
|
|
||||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
||||||
import { join, dirname } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import type { OpenRouterModel } from "./types.js";
|
|
||||||
import { loadModelInfo, getAvailableModels } from "./model-loader.js";
|
|
||||||
|
|
||||||
// Get __dirname equivalent in ESM
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
// Cache paths
|
|
||||||
const ALL_MODELS_JSON_PATH = join(__dirname, "../all-models.json");
|
|
||||||
const CACHE_MAX_AGE_DAYS = 2;
|
|
||||||
|
|
||||||
// Options for model selector
|
|
||||||
export interface ModelSelectorOptions {
|
|
||||||
freeOnly?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EnhancedModelData {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
provider: string;
|
|
||||||
pricing?: {
|
|
||||||
input: string;
|
|
||||||
output: string;
|
|
||||||
average: string;
|
|
||||||
};
|
|
||||||
context?: string;
|
|
||||||
supportsTools?: boolean;
|
|
||||||
supportsReasoning?: boolean;
|
|
||||||
supportsVision?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load enhanced model data from recommended-models.json
|
|
||||||
*/
|
|
||||||
function loadEnhancedModels(): EnhancedModelData[] {
|
|
||||||
const jsonPath = join(__dirname, "../recommended-models.json");
|
|
||||||
|
|
||||||
if (existsSync(jsonPath)) {
|
|
||||||
try {
|
|
||||||
const jsonContent = readFileSync(jsonPath, "utf-8");
|
|
||||||
const data = JSON.parse(jsonContent);
|
|
||||||
return data.models || [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Curated list of well-known providers for free models
|
|
||||||
const TRUSTED_FREE_PROVIDERS = [
|
|
||||||
"google",
|
|
||||||
"openai",
|
|
||||||
"x-ai",
|
|
||||||
"deepseek",
|
|
||||||
"qwen",
|
|
||||||
"alibaba",
|
|
||||||
"meta-llama",
|
|
||||||
"microsoft",
|
|
||||||
"mistralai",
|
|
||||||
"nvidia",
|
|
||||||
"cohere",
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load free models from OpenRouter (from cache or fetch)
|
|
||||||
* Only includes models from well-known, trusted providers
|
|
||||||
*/
|
|
||||||
async function loadFreeModels(): Promise<EnhancedModelData[]> {
|
|
||||||
let allModels: any[] = [];
|
|
||||||
|
|
||||||
// Try to load from cache first
|
|
||||||
if (existsSync(ALL_MODELS_JSON_PATH)) {
|
|
||||||
try {
|
|
||||||
const cacheData = JSON.parse(readFileSync(ALL_MODELS_JSON_PATH, "utf-8"));
|
|
||||||
const lastUpdated = new Date(cacheData.lastUpdated);
|
|
||||||
const now = new Date();
|
|
||||||
const ageInDays = (now.getTime() - lastUpdated.getTime()) / (1000 * 60 * 60 * 24);
|
|
||||||
|
|
||||||
if (ageInDays <= CACHE_MAX_AGE_DAYS) {
|
|
||||||
allModels = cacheData.models;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Cache error, will fetch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch if no cache or stale
|
|
||||||
if (allModels.length === 0) {
|
|
||||||
console.error("🔄 Fetching models from OpenRouter...");
|
|
||||||
try {
|
|
||||||
const response = await fetch("https://openrouter.ai/api/v1/models");
|
|
||||||
if (!response.ok) throw new Error(`API returned ${response.status}`);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
allModels = data.data;
|
|
||||||
|
|
||||||
// Cache result
|
|
||||||
writeFileSync(ALL_MODELS_JSON_PATH, JSON.stringify({
|
|
||||||
lastUpdated: new Date().toISOString(),
|
|
||||||
models: allModels
|
|
||||||
}), "utf-8");
|
|
||||||
|
|
||||||
console.error(`✅ Cached ${allModels.length} models`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ Failed to fetch models: ${error}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter for FREE models from TRUSTED providers only
|
|
||||||
const freeModels = allModels.filter(model => {
|
|
||||||
const promptPrice = parseFloat(model.pricing?.prompt || "0");
|
|
||||||
const completionPrice = parseFloat(model.pricing?.completion || "0");
|
|
||||||
const isFree = promptPrice === 0 && completionPrice === 0;
|
|
||||||
|
|
||||||
if (!isFree) return false;
|
|
||||||
|
|
||||||
// Check if provider is in trusted list
|
|
||||||
const provider = model.id.split('/')[0].toLowerCase();
|
|
||||||
return TRUSTED_FREE_PROVIDERS.includes(provider);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by context window size (largest first)
|
|
||||||
freeModels.sort((a, b) => {
|
|
||||||
const contextA = a.context_length || a.top_provider?.context_length || 0;
|
|
||||||
const contextB = b.context_length || b.top_provider?.context_length || 0;
|
|
||||||
return contextB - contextA;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Dedupe: prefer non-:free variant, remove duplicates
|
|
||||||
const seenBase = new Set<string>();
|
|
||||||
const dedupedModels = freeModels.filter(model => {
|
|
||||||
// Get base model ID (without :free suffix)
|
|
||||||
const baseId = model.id.replace(/:free$/, '');
|
|
||||||
if (seenBase.has(baseId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
seenBase.add(baseId);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Limit to top 15 models
|
|
||||||
const topModels = dedupedModels.slice(0, 15);
|
|
||||||
|
|
||||||
// Convert to EnhancedModelData format
|
|
||||||
return topModels.map(model => {
|
|
||||||
const provider = model.id.split('/')[0];
|
|
||||||
const contextLen = model.context_length || model.top_provider?.context_length || 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: model.id,
|
|
||||||
name: model.name || model.id,
|
|
||||||
description: model.description || '',
|
|
||||||
provider: provider.charAt(0).toUpperCase() + provider.slice(1),
|
|
||||||
pricing: {
|
|
||||||
input: "FREE",
|
|
||||||
output: "FREE",
|
|
||||||
average: "FREE"
|
|
||||||
},
|
|
||||||
context: contextLen > 0 ? `${Math.round(contextLen/1000)}K` : "N/A",
|
|
||||||
supportsTools: (model.supported_parameters || []).includes("tools"),
|
|
||||||
supportsReasoning: (model.supported_parameters || []).includes("reasoning"),
|
|
||||||
supportsVision: (model.architecture?.input_modalities || []).includes("image")
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prompt user for OpenRouter API key interactively
|
|
||||||
* Uses readline with proper stdin cleanup
|
|
||||||
*/
|
|
||||||
export async function promptForApiKey(): Promise<string> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
console.log("\n\x1b[1m\x1b[36mOpenRouter API Key Required\x1b[0m\n");
|
|
||||||
console.log("\x1b[2mGet your free API key from: https://openrouter.ai/keys\x1b[0m\n");
|
|
||||||
console.log("Enter your OpenRouter API key:");
|
|
||||||
console.log("\x1b[2m(it will not be saved, only used for this session)\x1b[0m\n");
|
|
||||||
|
|
||||||
const rl = createInterface({
|
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout,
|
|
||||||
terminal: false, // CRITICAL: Don't use terminal mode to avoid stdin interference
|
|
||||||
});
|
|
||||||
|
|
||||||
let apiKey: string | null = null;
|
|
||||||
|
|
||||||
rl.on("line", (input) => {
|
|
||||||
const trimmed = input.trim();
|
|
||||||
|
|
||||||
if (!trimmed) {
|
|
||||||
console.log("\x1b[31mError: API key cannot be empty\x1b[0m");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic validation: should start with sk-or-v1- (OpenRouter format)
|
|
||||||
if (!trimmed.startsWith("sk-or-v1-")) {
|
|
||||||
console.log("\x1b[33mWarning: OpenRouter API keys usually start with 'sk-or-v1-'\x1b[0m");
|
|
||||||
console.log("\x1b[2mContinuing anyway...\x1b[0m");
|
|
||||||
}
|
|
||||||
|
|
||||||
apiKey = trimmed;
|
|
||||||
rl.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
rl.on("close", () => {
|
|
||||||
// CRITICAL: Only resolve AFTER readline has fully closed
|
|
||||||
if (apiKey) {
|
|
||||||
// Force stdin to clean state
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdin.removeAllListeners("data");
|
|
||||||
process.stdin.removeAllListeners("end");
|
|
||||||
process.stdin.removeAllListeners("error");
|
|
||||||
process.stdin.removeAllListeners("readable");
|
|
||||||
|
|
||||||
// Ensure not in raw mode
|
|
||||||
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
||||||
process.stdin.setRawMode(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for stdin to fully detach
|
|
||||||
setTimeout(() => {
|
|
||||||
resolve(apiKey);
|
|
||||||
}, 200);
|
|
||||||
} else {
|
|
||||||
console.error("\x1b[31mError: API key is required\x1b[0m");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple console-based model selector (no Ink/React)
|
|
||||||
* Uses readline which properly cleans up stdin
|
|
||||||
*/
|
|
||||||
export async function selectModelInteractively(options: ModelSelectorOptions = {}): Promise<OpenRouterModel | string> {
|
|
||||||
const { freeOnly = false } = options;
|
|
||||||
|
|
||||||
// Load models based on mode
|
|
||||||
let displayModels: string[];
|
|
||||||
let enhancedMap: Map<string, EnhancedModelData>;
|
|
||||||
|
|
||||||
if (freeOnly) {
|
|
||||||
// Load free models from OpenRouter
|
|
||||||
const freeModels = await loadFreeModels();
|
|
||||||
|
|
||||||
if (freeModels.length === 0) {
|
|
||||||
console.error("❌ No free models found or failed to fetch models");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
displayModels = freeModels.map(m => m.id);
|
|
||||||
enhancedMap = new Map<string, EnhancedModelData>();
|
|
||||||
for (const m of freeModels) {
|
|
||||||
enhancedMap.set(m.id, m);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Load recommended models (default behavior)
|
|
||||||
displayModels = getAvailableModels();
|
|
||||||
const enhancedModels = loadEnhancedModels();
|
|
||||||
enhancedMap = new Map<string, EnhancedModelData>();
|
|
||||||
for (const m of enhancedModels) {
|
|
||||||
enhancedMap.set(m.id, m);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add custom option only for non-free mode
|
|
||||||
const models = freeOnly ? displayModels : displayModels;
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
// ANSI color codes
|
|
||||||
const RESET = "\x1b[0m";
|
|
||||||
const BOLD = "\x1b[1m";
|
|
||||||
const DIM = "\x1b[2m";
|
|
||||||
const CYAN = "\x1b[36m";
|
|
||||||
const GREEN = "\x1b[32m";
|
|
||||||
const YELLOW = "\x1b[33m";
|
|
||||||
const MAGENTA = "\x1b[35m";
|
|
||||||
|
|
||||||
// Helper to pad text (truncate if needed)
|
|
||||||
const pad = (text: string, width: number) => {
|
|
||||||
if (text.length > width) return text.slice(0, width - 3) + "...";
|
|
||||||
return text + " ".repeat(width - text.length);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Print header
|
|
||||||
const headerText = freeOnly ? "Select a FREE OpenRouter Model" : "Select an OpenRouter Model";
|
|
||||||
const headerPadding = " ".repeat(82 - 4 - headerText.length); // 82 total - 4 for borders/spacing
|
|
||||||
console.log("");
|
|
||||||
console.log(`${DIM}╭${"─".repeat(82)}╮${RESET}`);
|
|
||||||
console.log(`${DIM}│${RESET} ${BOLD}${CYAN}${headerText}${RESET}${headerPadding}${DIM}│${RESET}`);
|
|
||||||
console.log(`${DIM}├${"─".repeat(82)}┤${RESET}`);
|
|
||||||
|
|
||||||
// Column headers (74 chars content + 4 padding + 2 border = 80)
|
|
||||||
console.log(`${DIM}│${RESET} ${DIM}# Model Provider Pricing Context Caps${RESET} ${DIM}│${RESET}`);
|
|
||||||
console.log(`${DIM}├${"─".repeat(82)}┤${RESET}`);
|
|
||||||
|
|
||||||
// Display models - each row should be 82 chars inner content
|
|
||||||
models.forEach((modelId, index) => {
|
|
||||||
const num = (index + 1).toString().padStart(2);
|
|
||||||
const enhanced = enhancedMap.get(modelId);
|
|
||||||
|
|
||||||
if (modelId === "custom") {
|
|
||||||
// Custom model entry: 2+2+36 = 40 chars, need 80-40 = 40 padding
|
|
||||||
console.log(`${DIM}│${RESET} ${YELLOW}${num}${RESET} ${DIM}Enter custom OpenRouter model ID...${RESET}${" ".repeat(40)}${DIM}│${RESET}`);
|
|
||||||
} else if (enhanced) {
|
|
||||||
// Enhanced model with full info
|
|
||||||
const shortId = pad(modelId, 33);
|
|
||||||
const provider = pad(enhanced.provider || "N/A", 10);
|
|
||||||
const pricing = pad(enhanced.pricing?.average || "N/A", 9);
|
|
||||||
const context = pad(enhanced.context || "N/A", 7);
|
|
||||||
|
|
||||||
// Capability indicators
|
|
||||||
const tools = enhanced.supportsTools ? "✓" : "·";
|
|
||||||
const reasoning = enhanced.supportsReasoning ? "✓" : "·";
|
|
||||||
const vision = enhanced.supportsVision ? "✓" : "·";
|
|
||||||
|
|
||||||
// Content: 2+2+33+1+10+1+9+1+7+1+5 = 72 chars, need 80-72 = 8 padding
|
|
||||||
console.log(`${DIM}│${RESET} ${GREEN}${num}${RESET} ${BOLD}${shortId}${RESET} ${CYAN}${provider}${RESET} ${MAGENTA}${pricing}${RESET} ${context} ${tools} ${reasoning} ${vision} ${DIM}│${RESET}`);
|
|
||||||
} else {
|
|
||||||
// Fallback for models without enhanced data
|
|
||||||
const shortId = pad(modelId, 33);
|
|
||||||
console.log(`${DIM}│${RESET} ${GREEN}${num}${RESET} ${shortId} ${DIM}${pad("N/A", 10)} ${pad("N/A", 9)} ${pad("N/A", 7)}${RESET} · · · ${DIM}│${RESET}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Footer with legend: 36 chars content, need 80-36 = 44 padding
|
|
||||||
console.log(`${DIM}├${"─".repeat(82)}┤${RESET}`);
|
|
||||||
console.log(`${DIM}│${RESET} ${DIM}Caps: ✓/· = Tools, Reasoning, Vision${RESET}${" ".repeat(44)}${DIM}│${RESET}`);
|
|
||||||
console.log(`${DIM}╰${"─".repeat(82)}╯${RESET}`);
|
|
||||||
console.log("");
|
|
||||||
console.log(`${DIM}Enter number (1-${models.length}) or 'q' to quit:${RESET}`);
|
|
||||||
|
|
||||||
const rl = createInterface({
|
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout,
|
|
||||||
terminal: false, // CRITICAL: Don't use terminal mode to avoid stdin interference
|
|
||||||
});
|
|
||||||
|
|
||||||
let selectedModel: string | null = null;
|
|
||||||
|
|
||||||
rl.on("line", (input) => {
|
|
||||||
const trimmed = input.trim();
|
|
||||||
|
|
||||||
// Handle quit
|
|
||||||
if (trimmed.toLowerCase() === "q") {
|
|
||||||
rl.close();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse selection
|
|
||||||
const selection = parseInt(trimmed, 10);
|
|
||||||
if (isNaN(selection) || selection < 1 || selection > models.length) {
|
|
||||||
console.log(`\x1b[31mInvalid selection. Please enter 1-${models.length}\x1b[0m`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = models[selection - 1];
|
|
||||||
|
|
||||||
// Handle custom model
|
|
||||||
if (model === "custom") {
|
|
||||||
rl.close();
|
|
||||||
|
|
||||||
console.log("\n\x1b[1m\x1b[36mEnter custom OpenRouter model ID:\x1b[0m");
|
|
||||||
const customRl = createInterface({
|
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout,
|
|
||||||
terminal: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let customModel: string | null = null;
|
|
||||||
|
|
||||||
customRl.on("line", (customInput) => {
|
|
||||||
customModel = customInput.trim();
|
|
||||||
customRl.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
customRl.on("close", () => {
|
|
||||||
// CRITICAL: Wait for readline to fully detach before resolving
|
|
||||||
// Force stdin to clean state
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdin.removeAllListeners("data");
|
|
||||||
process.stdin.removeAllListeners("end");
|
|
||||||
process.stdin.removeAllListeners("error");
|
|
||||||
process.stdin.removeAllListeners("readable");
|
|
||||||
|
|
||||||
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
||||||
process.stdin.setRawMode(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (customModel) {
|
|
||||||
resolve(customModel);
|
|
||||||
} else {
|
|
||||||
console.error("\x1b[31mError: Model ID cannot be empty\x1b[0m");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
selectedModel = model;
|
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
rl.on("close", () => {
|
|
||||||
// CRITICAL: Only resolve AFTER readline has fully closed
|
|
||||||
// This ensures stdin is completely detached before spawning Claude Code
|
|
||||||
if (selectedModel) {
|
|
||||||
// Force stdin to clean state
|
|
||||||
// Pause to stop all event processing
|
|
||||||
process.stdin.pause();
|
|
||||||
|
|
||||||
// Remove ALL readline-related listeners
|
|
||||||
process.stdin.removeAllListeners("data");
|
|
||||||
process.stdin.removeAllListeners("end");
|
|
||||||
process.stdin.removeAllListeners("error");
|
|
||||||
process.stdin.removeAllListeners("readable");
|
|
||||||
|
|
||||||
// Ensure not in raw mode
|
|
||||||
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
||||||
process.stdin.setRawMode(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for stdin to fully detach (longer delay)
|
|
||||||
setTimeout(() => {
|
|
||||||
resolve(selectedModel);
|
|
||||||
}, 200); // 200ms delay for complete cleanup
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -32,6 +32,7 @@ export interface ClaudishConfig {
|
||||||
anthropicApiKey?: string; // Required in monitor mode
|
anthropicApiKey?: string; // Required in monitor mode
|
||||||
agent?: string; // Agent to use for execution (e.g., "frontend:developer")
|
agent?: string; // Agent to use for execution (e.g., "frontend:developer")
|
||||||
freeOnly?: boolean; // Show only free models in selector
|
freeOnly?: boolean; // Show only free models in selector
|
||||||
|
profile?: string; // Profile name to use for model mapping
|
||||||
claudeArgs: string[];
|
claudeArgs: string[];
|
||||||
|
|
||||||
// Model Mapping
|
// Model Mapping
|
||||||
|
|
@ -39,6 +40,11 @@ export interface ClaudishConfig {
|
||||||
modelSonnet?: string;
|
modelSonnet?: string;
|
||||||
modelHaiku?: string;
|
modelHaiku?: string;
|
||||||
modelSubagent?: string;
|
modelSubagent?: string;
|
||||||
|
|
||||||
|
// Cost tracking
|
||||||
|
costTracking?: boolean;
|
||||||
|
auditCosts?: boolean;
|
||||||
|
resetCosts?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anthropic API Types
|
// Anthropic API Types
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue