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:
Jack Rudenko 2025-11-28 21:57:03 +11:00
parent 95d716a9e4
commit a3303a12db
No known key found for this signature in database
GPG Key ID: E0CDF9F9DBB5E0B2
9 changed files with 1314 additions and 443 deletions

View File

@ -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=="],

View File

@ -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",

View File

@ -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

View File

@ -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
} }

493
src/model-selector.ts Normal file
View File

@ -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 });
}

435
src/profile-commands.ts Normal file
View File

@ -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
`);
}

265
src/profile-config.ts Normal file
View File

@ -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 })[];
}

View File

@ -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
}
});
});
}

View File

@ -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