From a3303a12dbb54b9e5c0d2eb0ff27b19814fd43c1 Mon Sep 17 00:00:00 2001 From: Jack Rudenko Date: Fri, 28 Nov 2025 21:57:03 +1100 Subject: [PATCH] feat(profiles): Add profile-based model configuration v2.8.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- bun.lock | 56 +++++ package.json | 4 +- src/cli.ts | 46 +++- src/index.ts | 14 +- src/model-selector.ts | 493 ++++++++++++++++++++++++++++++++++++++++ src/profile-commands.ts | 435 +++++++++++++++++++++++++++++++++++ src/profile-config.ts | 265 +++++++++++++++++++++ src/simple-selector.ts | 438 ----------------------------------- src/types.ts | 6 + 9 files changed, 1314 insertions(+), 443 deletions(-) create mode 100644 src/model-selector.ts create mode 100644 src/profile-commands.ts create mode 100644 src/profile-config.ts delete mode 100644 src/simple-selector.ts diff --git a/bun.lock b/bun.lock index 01f02ed..58790a6 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,8 @@ "name": "claudish", "dependencies": { "@hono/node-server": "^1.19.6", + "@inquirer/prompts": "^8.0.1", + "@inquirer/search": "^4.0.1", "@modelcontextprotocol/sdk": "^1.22.0", "dotenv": "^17.2.3", "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=="], + "@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=="], "@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=="], + "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=="], "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=="], + "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-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=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "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=="], + "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-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=="], + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "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=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "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=="], "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=="], + "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=="], "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], diff --git a/package.json b/package.json index c197120..8a06f06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudish", - "version": "2.7.0", + "version": "2.8.0", "description": "Run Claude Code with any OpenRouter model - CLI tool and MCP server", "type": "module", "main": "./dist/index.js", @@ -27,6 +27,8 @@ }, "dependencies": { "@hono/node-server": "^1.19.6", + "@inquirer/prompts": "^8.0.1", + "@inquirer/search": "^4.0.1", "@modelcontextprotocol/sdk": "^1.22.0", "dotenv": "^17.2.3", "hono": "^4.10.6", diff --git a/src/cli.ts b/src/cli.ts index dd1f65e..c737b1c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { fuzzyScore } from "./utils.js"; +import { getProfile, getDefaultProfile, getModelMapping } from "./profile-config.js"; // Read version from package.json const __filename = fileURLToPath(import.meta.url); @@ -92,7 +93,7 @@ export async function parseArgs(args: string[]): Promise { } else if (arg === "--model-subagent") { const val = args[++i]; if (val) config.modelSubagent = val; - } else if (arg === "--port" || arg === "-p") { + } else if (arg === "--port") { const portArg = args[++i]; if (!portArg) { console.error("--port requires a value"); @@ -131,6 +132,13 @@ export async function parseArgs(args: string[]): Promise { config.stdin = true; } else if (arg === "--free") { 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") { // Enable cost tracking for this session config.costTracking = true; @@ -257,6 +265,26 @@ export async function parseArgs(args: string[]): Promise { 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; } @@ -733,7 +761,8 @@ USAGE: OPTIONS: -i, --interactive Run in interactive mode (default when no prompt given) -m, --model OpenRouter model to use (required for single-shot mode) - -p, --port Proxy server port (default: random) + -p, --profile Use named profile for model mapping (default: uses default profile) + --port Proxy server port (default: random) -d, --debug Enable debug logging to file (logs/claudish_*.log) --log-level Log verbosity: debug (full), info (truncated), minimal (labels only) -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) --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 ) + claudish profile use Set default profile (interactive or claudish profile use ) + claudish profile show Show profile details (default profile or claudish profile show ) + claudish profile edit Edit a profile (interactive or claudish profile edit ) + MODEL MAPPING (per-role override): --model-opus Model for Opus role (planning, complex tasks) --model-sonnet Model for Sonnet role (default coding) @@ -814,6 +852,10 @@ EXAMPLES: # 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 + # 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 claudish --model-opus claude-3-opus-20240229 --model-sonnet x-ai/grok-code-fast-1 diff --git a/src/index.ts b/src/index.ts index 790ad88..4744352 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,9 +7,19 @@ config(); // Loads .env from current working directory // Check for MCP mode before loading heavy dependencies const isMcpMode = process.argv.includes("--mcp"); +// Check for profile management commands +const args = process.argv.slice(2); +const firstArg = args[0]; + if (isMcpMode) { // MCP server mode - dynamic import to keep CLI fast 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 { // CLI mode runCli(); @@ -22,7 +32,7 @@ async function runCli() { const { checkClaudeInstalled, runClaudeWithProxy } = await import("./claude-runner.js"); const { parseArgs, getVersion } = await import("./cli.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 { findAvailablePort } = await import("./port-manager.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 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 } diff --git a/src/model-selector.ts b/src/model-selector.ts new file mode 100644 index 0000000..ad46887 --- /dev/null +++ b/src/model-selector.ts @@ -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 { + // 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 { + 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(); + 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 { + 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 { + 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({ + 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 => { + const useCustom = await confirm({ + message: `Configure ${tier} model? (${description})`, + default: true, + }); + + if (!useCustom) return undefined; + + return search({ + 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 { + 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 { + 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 { + 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 { + 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 { + return confirm({ message, default: false }); +} diff --git a/src/profile-commands.ts b/src/profile-commands.ts new file mode 100644 index 0000000..c32402f --- /dev/null +++ b/src/profile-commands.ts @@ -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 : Remove a profile + * - claudish profile use : 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 [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 +`); +} diff --git a/src/profile-config.ts b/src/profile-config.ts new file mode 100644 index 0000000..90f446f --- /dev/null +++ b/src/profile-config.ts @@ -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; +} + +/** + * 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 })[]; +} diff --git a/src/simple-selector.ts b/src/simple-selector.ts deleted file mode 100644 index f9c1888..0000000 --- a/src/simple-selector.ts +++ /dev/null @@ -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 { - 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(); - 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 { - 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 { - const { freeOnly = false } = options; - - // Load models based on mode - let displayModels: string[]; - let enhancedMap: Map; - - 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(); - for (const m of freeModels) { - enhancedMap.set(m.id, m); - } - } else { - // Load recommended models (default behavior) - displayModels = getAvailableModels(); - const enhancedModels = loadEnhancedModels(); - enhancedMap = new Map(); - 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 - } - }); - }); -} diff --git a/src/types.ts b/src/types.ts index 6e72b1b..cd4c7ab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,6 +32,7 @@ export interface ClaudishConfig { anthropicApiKey?: string; // Required in monitor mode agent?: string; // Agent to use for execution (e.g., "frontend:developer") freeOnly?: boolean; // Show only free models in selector + profile?: string; // Profile name to use for model mapping claudeArgs: string[]; // Model Mapping @@ -39,6 +40,11 @@ export interface ClaudishConfig { modelSonnet?: string; modelHaiku?: string; modelSubagent?: string; + + // Cost tracking + costTracking?: boolean; + auditCosts?: boolean; + resetCosts?: boolean; } // Anthropic API Types