diff --git a/.gitignore b/.gitignore index 105112f1..af58b191 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,6 @@ dist/ # Package lock files (keep package-lock.json but ignore others) yarn.lock -pnpm-lock.yaml \ No newline at end of file +pnpm-lock.yaml + +.claude/active-plan \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 190e4870..150b0492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,42 @@ +# [5.4.0-beta.3](https://github.com/kaitranntt/ccs/compare/v5.4.0-beta.2...v5.4.0-beta.3) (2025-12-02) + + +### Bug Fixes + +* **cliproxy:** convert windows backslashes to forward slashes in config.yaml auth-dir ([a6663cb](https://github.com/kaitranntt/ccs/commit/a6663cbd0471d1a08e8bbcdea897760b434ae937)), closes [#36](https://github.com/kaitranntt/ccs/issues/36) + +# [5.4.0-beta.2](https://github.com/kaitranntt/ccs/compare/v5.4.0-beta.1...v5.4.0-beta.2) (2025-12-02) + + +### Bug Fixes + +* **auth:** prevent default profile from using stale glm env vars ([13d13da](https://github.com/kaitranntt/ccs/commit/13d13dab516332bc17345dc77afd44ae48bdd2aa)), closes [#37](https://github.com/kaitranntt/ccs/issues/37) + +# [5.4.0-beta.1](https://github.com/kaitranntt/ccs/compare/v5.3.0...v5.4.0-beta.1) (2025-12-02) + + +### Bug Fixes + +* **cliproxy:** improve qwen oauth error handling ([7e0b0fe](https://github.com/kaitranntt/ccs/commit/7e0b0feca8ce2ed5d505c5bf6c84e54c6df8839e)), closes [#29](https://github.com/kaitranntt/ccs/issues/29) +* **cliproxy:** use double-dash flags for cliproxyapi auth ([#24](https://github.com/kaitranntt/ccs/issues/24)) ([4c81f28](https://github.com/kaitranntt/ccs/commit/4c81f28f0b67ef92cf74d0f5c13a5943ff0a7f00)) +* **deps:** add chalk, boxen, gradient-string, listr2 as dependencies ([a214749](https://github.com/kaitranntt/ccs/commit/a214749725cfe05612e2c84cefa2ab3f619c6a2e)) +* **glmt:** handle 401 errors and headers-already-sent exception ([#30](https://github.com/kaitranntt/ccs/issues/30)) ([c953382](https://github.com/kaitranntt/ccs/commit/c95338232a37981b95b785b47185ce18d6d94b7a)), closes [#26](https://github.com/kaitranntt/ccs/issues/26) +* **prompt:** improve password input handling with raw mode and buffer support ([bc56d2e](https://github.com/kaitranntt/ccs/commit/bc56d2e135532b2ae443144dd42217b26bcba951)) + + +### Features + +* **cliproxy:** add qwen code oauth provider support ([#31](https://github.com/kaitranntt/ccs/issues/31)) ([a3f1e52](https://github.com/kaitranntt/ccs/commit/a3f1e52ac68600ba0806d67aacceb6477ffa3543)), closes [#29](https://github.com/kaitranntt/ccs/issues/29) +* **cliproxy:** auto-update cliproxyapi to latest release on startup ([8873ccd](https://github.com/kaitranntt/ccs/commit/8873ccd981679e8acff8965accdc22215c6e4aa2)) +* **profile:** add profile command with configuration display ([53d7a15](https://github.com/kaitranntt/ccs/commit/53d7a15c047e760723e051dc0f7be3c0dd42d087)) +* **shell-completion:** add --force flag and fix zsh profile coloring ([7faed1d](https://github.com/kaitranntt/ccs/commit/7faed1d84ba29ba02bf687bae5b3458617512e67)) +* **ui:** add central ui abstraction layer for cli styling ([6e49e0e](https://github.com/kaitranntt/ccs/commit/6e49e0e7e157abd4a38c98553dbe3c16473b57d9)) +* **ui:** enhance auth commands with new ui layer ([6f42a65](https://github.com/kaitranntt/ccs/commit/6f42a6527b1bf02cbf29ec23525c9f27af6f0c98)) +* **ui:** enhance delegation with listr2 task lists and styled output ([716193a](https://github.com/kaitranntt/ccs/commit/716193a682a1504767c7f32409a0de51278242eb)) +* **ui:** enhance doctor and error manager with new ui layer ([57016f3](https://github.com/kaitranntt/ccs/commit/57016f3f765f207915161514e1827b18c0b03d5c)) +* **ui:** enhance help and profile commands with new ui layer ([f3ed359](https://github.com/kaitranntt/ccs/commit/f3ed359050ce66d96c0109cf60c242bfd092114d)) +* **ui:** enhance section headers with gradient and rename profile to api ([073a5e1](https://github.com/kaitranntt/ccs/commit/073a5e15ee8f895d7485864526d8946b774bb728)) + # [5.3.0](https://github.com/kaitranntt/ccs/compare/v5.2.1...v5.3.0) (2025-12-01) diff --git a/VERSION b/VERSION index 03f488b0..30f74930 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.3.0 +5.4.0-beta.3 diff --git a/bun.lock b/bun.lock index 0df6f468..7b0048f6 100644 --- a/bun.lock +++ b/bun.lock @@ -4,7 +4,11 @@ "": { "name": "@kaitranntt/ccs", "dependencies": { + "boxen": "^8.0.1", + "chalk": "^5.6.2", "cli-table3": "^0.6.5", + "gradient-string": "^3.0.0", + "listr2": "^9.0.5", "ora": "^9.0.0", }, "devDependencies": { @@ -164,6 +168,8 @@ "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], + "@types/tinycolor2": ["@types/tinycolor2@1.4.6", "", {}, "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.48.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/type-utils": "8.48.0", "@typescript-eslint/utils": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.48.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.48.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", "@typescript-eslint/typescript-estree": "8.48.0", "@typescript-eslint/visitor-keys": "8.48.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ=="], @@ -196,6 +202,8 @@ "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], + "ansi-escapes": ["ansi-escapes@7.2.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -216,6 +224,8 @@ "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], + "boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -224,9 +234,9 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], + "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], @@ -234,6 +244,8 @@ "clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], @@ -242,12 +254,16 @@ "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + "compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], @@ -296,7 +312,7 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="], @@ -330,6 +346,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], @@ -390,6 +408,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "gradient-string": ["gradient-string@3.0.0", "", { "dependencies": { "chalk": "^5.3.0", "tinygradient": "^1.1.5" } }, "sha512-frdKI4Qi8Ihp4C6wZNB565de/THpIaw3DjP5ku87M+N9rNSGmPTjfkq61SdRXB7eCaL8O1hkKDvf6CDMtOzIAg=="], + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], @@ -492,6 +512,8 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], + "load-json-file": ["load-json-file@4.0.0", "", { "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^4.0.0", "pify": "^3.0.0", "strip-bom": "^3.0.0" } }, "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -528,6 +550,8 @@ "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], + "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + "lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="], "make-asynchronous": ["make-asynchronous@1.0.1", "", { "dependencies": { "p-event": "^6.0.0", "type-fest": "^4.6.0", "web-worker": "1.2.0" } }, "sha512-T9BPOmEOhp6SmV25SwLVcHK4E6JyG/coH3C6F1NjNXSziv/fd4GmsqMk8YR6qpPOswfaOCApSNkZv6fxoaYFcQ=="], @@ -666,6 +690,8 @@ "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "semantic-release": ["semantic-release@25.0.2", "", { "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", "@semantic-release/github": "^12.0.0", "@semantic-release/npm": "^13.1.1", "@semantic-release/release-notes-generator": "^14.1.0", "aggregate-error": "^5.0.0", "cosmiconfig": "^9.0.0", "debug": "^4.0.0", "env-ci": "^11.0.0", "execa": "^9.0.0", "figures": "^6.0.0", "find-versions": "^6.0.0", "get-stream": "^6.0.0", "git-log-parser": "^1.2.0", "hook-std": "^4.0.0", "hosted-git-info": "^9.0.0", "import-from-esm": "^2.0.0", "lodash-es": "^4.17.21", "marked": "^15.0.0", "marked-terminal": "^7.3.0", "micromatch": "^4.0.2", "p-each-series": "^3.0.0", "p-reduce": "^3.0.0", "read-package-up": "^12.0.0", "resolve-from": "^5.0.0", "semver": "^7.3.2", "semver-diff": "^5.0.0", "signale": "^1.2.1", "yargs": "^18.0.0" }, "bin": { "semantic-release": "bin/semantic-release.js" } }, "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g=="], @@ -688,6 +714,8 @@ "skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="], + "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "spawn-error-forwarder": ["spawn-error-forwarder@1.0.0", "", {}, "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g=="], @@ -706,7 +734,7 @@ "stream-combiner2": ["stream-combiner2@1.1.1", "", { "dependencies": { "duplexer2": "~0.1.0", "readable-stream": "^2.0.2" } }, "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw=="], - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "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=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -746,10 +774,14 @@ "time-span": ["time-span@5.1.0", "", { "dependencies": { "convert-hrtime": "^5.0.0" } }, "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA=="], + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinygradient": ["tinygradient@1.1.5", "", { "dependencies": { "@types/tinycolor2": "^1.4.0", "tinycolor2": "^1.0.0" } }, "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "traverse": ["traverse@0.6.8", "", {}, "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA=="], @@ -760,7 +792,7 @@ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "type-fest": ["type-fest@5.2.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA=="], + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "typescript": ["typescript@5.3.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw=="], @@ -792,13 +824,15 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], "workerpool": ["workerpool@9.3.4", "", {}, "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg=="], - "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "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=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -820,18 +854,12 @@ "@commitlint/config-validator/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], - "@commitlint/format/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - - "@commitlint/load/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "@commitlint/parse/conventional-changelog-angular": ["conventional-changelog-angular@7.0.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ=="], "@commitlint/parse/conventional-commits-parser": ["conventional-commits-parser@5.0.0", "", { "dependencies": { "JSONStream": "^1.3.5", "is-text-path": "^2.0.0", "meow": "^12.0.1", "split2": "^4.0.0" }, "bin": { "conventional-commits-parser": "cli.mjs" } }, "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA=="], "@commitlint/top-level/find-up": ["find-up@7.0.0", "", { "dependencies": { "locate-path": "^7.2.0", "path-exists": "^5.0.0", "unicorn-magic": "^0.1.0" } }, "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g=="], - "@commitlint/types/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -858,12 +886,22 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "cli-highlight/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], + "cli-table3/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "cli-truncate/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], + + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "conventional-changelog-writer/meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], "conventional-commits-parser/meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], @@ -872,6 +910,8 @@ "env-ci/execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], + "eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "fdir/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -888,12 +928,10 @@ "load-json-file/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], + "log-symbols/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "log-symbols/is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], - "make-asynchronous/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], - - "marked-terminal/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "mocha/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "npm/@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], @@ -1210,8 +1248,6 @@ "npm/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "ora/log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], "ora/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], @@ -1224,8 +1260,12 @@ "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "read-package-up/type-fest": ["type-fest@5.2.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA=="], + "read-pkg/parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], + "read-pkg/type-fest": ["type-fest@5.2.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA=="], + "readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], @@ -1246,7 +1286,11 @@ "signale/figures": ["figures@2.0.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA=="], - "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1262,10 +1306,16 @@ "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "yargs-unparser/camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], + "@commitlint/config-validator/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@commitlint/parse/conventional-commits-parser/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], @@ -1304,14 +1354,26 @@ "@semantic-release/release-notes-generator/read-package-up/read-pkg": ["read-pkg@9.0.1", "", { "dependencies": { "@types/normalize-package-data": "^2.4.3", "normalize-package-data": "^6.0.0", "parse-json": "^8.0.0", "type-fest": "^4.6.0", "unicorn-magic": "^0.1.0" } }, "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA=="], - "@semantic-release/release-notes-generator/read-package-up/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "cli-highlight/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], + "cli-highlight/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "cli-highlight/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], + "cli-table3/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "cli-table3/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "env-ci/execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], @@ -1328,8 +1390,12 @@ "env-ci/execa/strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], + "eslint/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "log-symbols/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "mocha/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "npm/@sigstore/sign/proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], @@ -1370,8 +1436,6 @@ "semantic-release/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], - "semantic-release/yargs/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=="], - "semantic-release/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], "signale/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], @@ -1384,11 +1448,13 @@ "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "@commitlint/top-level/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="], @@ -1404,8 +1470,18 @@ "@semantic-release/release-notes-generator/read-package-up/read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "cli-highlight/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "cli-highlight/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "cli-highlight/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "cli-highlight/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "cli-table3/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "env-ci/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "env-ci/execa/onetime/mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="], @@ -1424,23 +1500,21 @@ "semantic-release/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - "semantic-release/yargs/cliui/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=="], - - "semantic-release/yargs/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - "signale/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "signale/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], + "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@commitlint/top-level/find-up/locate-path/p-locate/p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="], "@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], "cli-highlight/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "pkg-conf/find-up/locate-path/p-locate/p-limit": ["p-limit@1.3.0", "", { "dependencies": { "p-try": "^1.0.0" } }, "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q=="], + "cli-highlight/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "semantic-release/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "pkg-conf/find-up/locate-path/p-locate/p-limit": ["p-limit@1.3.0", "", { "dependencies": { "p-try": "^1.0.0" } }, "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q=="], "signale/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], diff --git a/docs/code-standards.md b/docs/code-standards.md index ae7e891d..dd8a0953 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -435,7 +435,142 @@ for (const [flag, handler] of Object.entries(commandHandlers)) { } ``` -## Delegation System Patterns (v4.0+) +## UI System Patterns (Phase 5, v4.5.0+) + +### Central UI Module (src/utils/ui.ts) + +#### Initialization Pattern +```typescript +import { ui } from '../utils/ui'; + +// Initialize UI (call once at startup) +async function main() { + await ui.init(); + // UI functions now available +} +``` + +#### Semantic Color Usage +```typescript +// Apply semantic colors +ui.color('Success!', 'success') // Green bold +ui.color('Error!', 'error') // Red bold +ui.color('Warning!', 'warning') // Yellow +ui.color('Info', 'info') // Cyan +ui.color('Command: ccs', 'command') // Yellow italic +ui.color('/path/to/file', 'path') // Cyan underline +``` + +#### ASCII Status Indicators (NO EMOJIS) +```typescript +// Preferred: ASCII indicators only +ui.ok('Installation complete') // [OK] Installation complete +ui.fail('Build failed') // [X] Build failed +ui.warn('Deprecated feature') // [!] Deprecated feature +ui.info('Checking system') // [i] Checking system + +// NEVER use emoji (violates CLAUDE.md) +// DON'T: console.log('✓ Success') or '❌ Error' +``` + +#### Box & Table Rendering +```typescript +// Styled boxes +ui.box('Content here', { + title: 'Header', + borderColor: '#00ECFA', + padding: 1 +}); + +ui.errorBox('Error message', 'ERROR'); +ui.infoBox('Info content', 'INFO'); + +// Styled tables +ui.table([ + ['Column 1', 'Column 2'], + ['Data 1', 'Data 2'] +], { + head: ['Header 1', 'Header 2'], + style: 'unicode' +}); +``` + +#### Spinner & Progress +```typescript +// Simple spinner (falls back to plain text in non-TTY) +const spin = await ui.spinner('Processing...'); +await doWork(); +spin.succeed('Done!'); + +// Spinner with message update +const spin = await ui.spinner('Loading'); +spin.update('Still loading...'); +spin.succeed('Completed'); +``` + +#### Task Lists (Listr2 Integration - Phase 5) +```typescript +// Create task list with intelligent renderer selection +const ctx = await ui.taskList([ + { + title: 'Install dependencies', + task: async () => { + await installDeps(); + } + }, + { + title: 'Build project', + task: async () => { + await build(); + }, + skip: () => !needsBuild() ? 'Already built' : undefined + } +], { + concurrent: false // Set true for parallel execution +}); + +// Renderer selection (automatic): +// - TTY: Default renderer with subtask hierarchy +// - Non-TTY: Simple renderer (plain text) +// - CI environment: Simple renderer +// - Claude Code context: Fallback spinner-based +``` + +#### Context Detection +```typescript +// Check if running in interactive TTY +if (ui.isInteractive()) { + // Can use full spinner/box features +} + +// Check if running inside Claude Code tool +if (ui.isClaudeCodeContext()) { + // May need fallback rendering +} +``` + +### UI Compliance Rules (STRICT) + +**Mandatory**: +- ALWAYS call `await ui.init()` before using any UI functions +- ASCII status indicators ONLY: [OK], [X], [!], [i] - NO EMOJIS +- Respect NO_COLOR and FORCE_COLOR environment variables +- Use semantic colors (success, error, warning, info, dim, primary, secondary, command, path) + +**Patterns**: +- Status messages: `ui.ok()`, `ui.fail()`, `ui.warn()`, `ui.info()` +- Styled output: `ui.box()`, `ui.table()`, `ui.spinner()` +- Progress: Use `ui.taskList()` for multi-step operations (Phase 5) +- Fallback-first: Always test in non-TTY (pipe) mode + +**Testing**: +```bash +# Test in non-TTY (pipes/CI) +ccs doctor | cat # Forces plain text output +NO_COLOR=1 ccs doctor # Disables colors +``` + +## Delegation System Patterns (v4.0+, Phase 5 Enhanced) ### Stream-JSON Parsing @@ -532,12 +667,76 @@ function executeHeadless(claudeCli, profile, prompt, sessionId = null) { } ``` -### Result Formatting Pattern +### Result Formatting Pattern (Phase 5 Enhanced - Async) -#### Cost and Duration Extraction -```javascript -// Extract cost and duration from Claude CLI output -function parseExecutionStats(output) { +#### Async Result Formatter with UI Integration +```typescript +// result-formatter.ts - Phase 5 now fully async +import { ui } from '../utils/ui'; + +class ResultFormatter { + // Initialize UI and format result + static async format(result: ExecutionResult): Promise { + await ui.init(); // Phase 5: Initialize UI layer + + // Build styled output + let output = ''; + + // Header box with status indicator + const modelName = this.getModelDisplayName(result.profile); + const headerIcon = result.success ? '[i]' : '[X]'; + output += ui.box(`${headerIcon} Delegated to ${modelName}`, { + borderStyle: 'round', + padding: 0, + }); + output += '\n\n'; + + // Info table with styled columns + output += this.formatInfoTable(result); + + // Error details if needed + if (result.errors && result.errors.length > 0) { + output += '\n' + ui.errorBox('Execution Errors', 'ERRORS'); + } + + return output; + } + + // Phase 5: Table formatting + private static formatInfoTable(result: ExecutionResult): string { + const rows = [ + ['Working Directory', result.cwd], + ['Model', this.getModelDisplayName(result.profile)], + ['Duration', `${(result.duration / 1000).toFixed(2)}s`], + ['Exit Code', result.exitCode === 0 ? '[OK]' : '[X]'], + ]; + + if (result.totalCost) { + rows.push(['Total Cost', `$${result.totalCost.toFixed(4)}`]); + } + + return ui.table(rows, { + style: 'unicode', + wordWrap: true + }); + } +} + +// Usage in delegation-handler.ts +const formatted = await ResultFormatter.format(result); // Phase 5: Now async! +console.log(formatted); +``` + +#### Cost and Duration Extraction (Unchanged from v4.0) +```typescript +// Extract cost and duration from stream-JSON output +interface ExecutionStats { + cost: number | null; + duration: number | null; + exitCode: number; +} + +function parseExecutionStats(output: string): ExecutionStats { const costRegex = /Cost:\s+\$(\d+\.\d+)/; const durationRegex = /Duration:\s+(\d+)ms/; @@ -546,20 +745,10 @@ function parseExecutionStats(output) { return { cost: costMatch ? parseFloat(costMatch[1]) : null, - duration: durationMatch ? parseInt(durationMatch[1]) : null + duration: durationMatch ? parseInt(durationMatch[1]) : null, + exitCode: 0 }; } - -// Format results -function formatResults(exitCode, stats) { - const status = exitCode === 0 ? '[OK]' : '[X]'; - const costStr = stats.cost ? `\$${stats.cost.toFixed(4)}` : 'N/A'; - const durationStr = stats.duration ? `${(stats.duration / 1000).toFixed(2)}s` : 'N/A'; - - console.log(`\n${status} Execution complete`); - console.log(` Cost: ${costStr}`); - console.log(` Duration: ${durationStr}`); -} ``` ## Symlinking Patterns (v4.1+) @@ -960,10 +1149,19 @@ These code standards ensure the CCS codebase remains: - **Compatible**: Cross-platform symlinking, Windows fallbacks, unified behavior - **Extensible**: Clean subsystem boundaries, reusable patterns - **Type-Safe**: Enhanced ESLint strictness enforces zero-tolerance for code quality issues +- **User-Friendly**: Semantic UI system with intelligent fallback rendering (Phase 5) + +**Phase 5 (2025-12-01) - UI & Listr2 Integration**: +- **Central UI Module**: src/utils/ui.ts with semantic colors, status indicators, boxes, tables +- **Listr2 Integration**: Task list progress with intelligent renderer selection +- **Claude Code Detection**: Automatic fallback for tool context (isClaudeCodeContext) +- **Async Formatting**: Result formatter now async with ui.init() call +- **Compliance**: ASCII-only indicators ([OK], [X], [!], [i]), NO_COLOR respect **v4.x Specific Standards**: - **Delegation patterns**: Stream-JSON parsing, session management, headless execution - **Symlinking patterns**: Cross-platform symlink creation, validation, repair +- **UI patterns**: Semantic colors, status indicators, boxes, tables, spinners, task lists - **Subsystem organization**: Clear separation (auth, delegation, glmt, management, utils) - **Naming conventions**: handler, executor, manager, validator, formatter, parser suffixes @@ -973,4 +1171,4 @@ These code standards ensure the CCS codebase remains: - **No shell dependencies**: error-codes.sh, progress-indicator.sh, prompt.sh removed - **First-run bootstrap**: Auto-installs @kaitranntt/ccs npm package globally -Following these standards helps maintain the quality, modularity, and extensibility of the v4.x architecture while enabling future development with AI delegation, shared data management, and comprehensive diagnostics. \ No newline at end of file +Following these standards helps maintain the quality, modularity, and extensibility of the v4.x architecture while enabling future development with AI delegation, shared data management, and comprehensive diagnostics. Phase 5 adds a professional UI layer that gracefully degrades in non-TTY environments while maintaining strict compliance with project constraints. \ No newline at end of file diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index b2839ae2..381d6604 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -6,7 +6,7 @@ CCS (Claude Code Switch) v4.5.0 is a lightweight CLI wrapper enabling instant pr ## Version Evolution -### v4.5.0 Architecture (Current, Phase 02 Complete - 2025-11-28) +### v4.5.0 Architecture (Current, Phase 02 Complete - 2025-11-28, UI Phase 1 - 2025-12-01) - **Total LOC**: ~8,477 lines (JavaScript/TypeScript) - **Main File**: src/ccs.ts - 593 lines (**44.6% reduction** from 1,071 lines) - **Key Features**: AI delegation, stream-JSON output, shell completion, doctor diagnostics, sync command @@ -23,7 +23,7 @@ CCS (Claude Code Switch) v4.5.0 is a lightweight CLI wrapper enabling instant pr - **Phase 02 (2025-11-28)**: Modular command architecture, native installer deprecation, main file 44.6% reduction - **v4.5.0**: npm-first distribution, TypeScript package with quality gates, modular commands, text-only output (no emojis) -## Core Components (Phase 02 Complete - 2025-11-28) +## Core Components (Phase 05 Complete - 2025-12-01 | UI Phase 1 + Listr2) ### 1. Main Entry Point (`src/ccs.ts` - 593 lines, 44.6% reduction) @@ -103,16 +103,16 @@ interface CommandHandler { - **shell-executor.ts** (1.5KB): Cross-platform shell command execution with process management - **package-manager-detector.ts** (3.8KB): Package manager detection (npm, yarn, pnpm, bun) -### 3. Delegation System (`src/delegation/` - ~1,200 lines) +### 3. Delegation System (`src/delegation/` - ~1,200 lines, Phase 5 Enhanced) -**New in v4.0**: Complete delegation subsystem +**New in v4.0**: Complete delegation subsystem; **Phase 5 Enhanced** with UI layer & Listr2 **Components**: -- **delegation-handler.js** (~300 lines): Routes `-p` commands, validates profiles -- **headless-executor.js** (~400 lines): Executes Claude CLI in headless mode with stream-JSON -- **session-manager.js** (~200 lines): Manages delegation session persistence (continue support) -- **result-formatter.js** (~150 lines): Formats execution results with cost/duration -- **settings-parser.js** (~150 lines): Parses profile settings for validation +- **delegation-handler.ts** (~300 lines): Routes `-p` commands, validates profiles; async formatting +- **headless-executor.ts** (~400 lines): Executes Claude CLI in headless mode with stream-JSON; UI progress +- **session-manager.ts** (~200 lines): Manages delegation session persistence (continue support) +- **result-formatter.ts** (~150 lines): **Async formatting** with styled boxes and tables (Phase 5) +- **settings-parser.ts** (~150 lines): Parses profile settings for validation **Key Features**: - **Stream-JSON output**: Real-time tool visibility (`--output-format stream-json --verbose`) @@ -121,6 +121,11 @@ interface CommandHandler { - **Signal handling**: Ctrl+C kills child processes properly - **Cost tracking**: USD cost display per delegation - **13 Claude Code tools** supported: Bash, Read, Write, Edit, Glob, Grep, NotebookEdit, SlashCommand, TodoWrite, etc. +- **Phase 5 Enhancements**: + - **Styled output**: UI layer integration for semantic boxes and tables + - **Async formatting**: Result formatter now fully async with ui.init() call + - **Listr2 integration**: Optional task list progress in TTY mode + - **Fallback chain**: Graceful degradation for non-TTY/CI environments **Delegation Flow**: ``` @@ -271,19 +276,20 @@ src/ # TypeScript source files (Phase 02 Modular Archite │ ├── instance-manager.ts # Instance lifecycle (~220 lines) │ ├── recovery-manager.ts # Auto-recovery (~80 lines) │ └── shared-manager.ts # Shared symlinking (~50 lines) -├── utils/ # Utilities (expanded in v4.x + Phase 02) +├── utils/ # Utilities (expanded in v4.x + Phase 02 + Phase 05, UI + Listr2) │ ├── claude-detector.ts # CLI detection (~70 lines) │ ├── claude-dir-installer.ts # .claude/ installer (v4.1.1, ~150 lines) │ ├── claude-symlink-manager.ts # Selective symlinks (v4.1, ~200 lines) │ ├── config-manager.ts # Config management (~80 lines) -│ ├── shell-executor.ts # 1.5KB - Cross-platform execution (Phase 02 NEW) -│ ├── package-manager-detector.ts # 3.8KB - Package manager detection (Phase 02 NEW) -│ ├── delegation-validator.js # Delegation validation (v4.0, ~100 lines) -│ ├── error-codes.js # Error codes (~50 lines) -│ ├── error-manager.js # Error handling (~200 lines) -│ ├── helpers.js # Utilities (~100 lines) -│ ├── progress-indicator.js # Progress display (~150 lines) -│ ├── prompt.js # User prompting (~100 lines) +│ ├── shell-executor.ts # 1.5KB - Cross-platform execution (Phase 02) +│ ├── package-manager-detector.ts # 3.8KB - Package manager detection (Phase 02) +│ ├── ui.ts # 5.2KB - Central UI abstraction (Phase 5, Listr2 integration) +│ ├── delegation-validator.ts # Delegation validation (v4.0, ~100 lines) +│ ├── error-codes.ts # Error codes (~50 lines) +│ ├── error-manager.ts # Error handling (~200 lines) +│ ├── helpers.ts # Utilities (~100 lines) +│ ├── progress-indicator.ts # Progress display (~150 lines) +│ ├── prompt.ts # User prompting (~100 lines) │ ├── shell-completion.ts # Shell completion (v4.1.4, ~250 lines) │ └── update-checker.ts # Update checker (v4.1, ~100 lines) ├── types/ # TypeScript type definitions @@ -424,7 +430,28 @@ ccs.js: execClaude(["command"], {CLAUDE_CONFIG_DIR: instancePath}) Claude CLI: Read credentials from instance, execute ``` -## Key Features (v4.5.0 - Phase 02 Complete) +## Key Features (v4.5.0 - Phase 02 Complete, UI Phase 1) + +### 0. Central UI Abstraction Layer (UI Phase 1 - 2025-12-01, Phase 5 Complete) +- **New Module**: src/utils/ui.ts (5.2KB) - Semantic, TTY-aware CLI styling +- **Dependencies**: chalk@5.6.2, boxen@8.0.1, gradient-string@3.0.0, cli-table3@0.6.5, ora@5.4.1, listr2@8.0.0 +- **Features**: + - Semantic color system (success, error, warning, info, dim, primary, secondary, command, path) + - ASCII-only status indicators ([OK], [X], [!], [i]) - NO EMOJIS per CLAUDE.md + - TTY-aware output (respects NO_COLOR, FORCE_COLOR env vars) + - Box rendering (with fallback ASCII renderer) + - Table rendering via cli-table3 + - Spinner/progress (ora wrapper with fallback) + - Section headers with optional gradient + - **Listr2 task lists** (Phase 5 NEW) - intelligent renderer selection: + - TTY mode: Default renderer with subtask hierarchy + - Non-TTY/CI: Simple renderer for clean output + - Claude Code detection: Automatic fallback for tool context + - Lazy loading of ESM modules for CommonJS compatibility +- **Type Support**: SemanticColor, BoxOptions, TableOptions, SpinnerOptions, SpinnerController, TaskItem, TaskListOptions +- **Compliance**: Strict CLAUDE.md adherence (no emojis, TTY-aware, NO_COLOR respect) +- **Fallback Architecture**: Works in non-TTY environments with graceful degradation +- **Claude Code Integration** (Phase 5): `isClaudeCodeContext()` detection for adaptive UI rendering ### 1. npm-First Installation (Phase 02 - 2025-11-28) - **Recommended method**: All users directed to npm installation @@ -552,6 +579,22 @@ Claude CLI: Read credentials from instance, execute ## Summary +**CCS Phase 5 Achievements (2025-12-01, UI + Listr2 Integration)**: +- **Listr2 Task Lists**: Integrated task list progress display with intelligent renderer selection +- **Claude Code Detection**: Automatic fallback for tool context via `isClaudeCodeContext()` +- **Async Result Formatting**: Delegation system now fully async with ui initialization +- **UI Layer Integration**: Styled boxes and tables for enhanced delegation output +- **Adaptive Rendering**: TTY (default), non-TTY (simple), CI (simple), Claude Code (fallback) +- **Type System**: TaskItem, TaskListOptions for task list operations + +**CCS UI Phase 1 Achievements (2025-12-01)**: +- **Central UI Module**: Introduced src/utils/ui.ts for semantic, TTY-aware CLI styling +- **CLAUDE.md Compliance**: ASCII-only indicators, NO_COLOR respect, TTY detection +- **ESM Compatibility**: Lazy loading strategy for chalk, boxen, gradient-string, ora in CommonJS project +- **Fallback Architecture**: Works in non-TTY (pipes/CI) with graceful degradation to plain text +- **Type System**: Complete TypeScript definitions for all UI functions +- **Color Palette**: Professional cyan-to-blue gradient (#00ECFA to #0099FF) + **CCS Phase 02 Achievements (2025-11-28)**: - **npm-First Distribution**: Deprecated native shell installers, all users directed to npm - **Modular Command Architecture**: 6 specialized command handlers with single responsibility principle diff --git a/installers/install.ps1 b/installers/install.ps1 index 919c42fe..3af049e9 100644 --- a/installers/install.ps1 +++ b/installers/install.ps1 @@ -83,7 +83,7 @@ $InstallMethod = if ($ScriptDir -and ((Test-Path "$ScriptDir\lib\ccs.ps1") -or ( # IMPORTANT: Update this version when releasing new versions! # This hardcoded version is used for standalone installations (irm | iex) # For git installations, VERSION file is read if available -$CcsVersion = "5.3.0" +$CcsVersion = "5.4.0-beta.3" # Try to read VERSION file for git installations if ($ScriptDir) { diff --git a/installers/install.sh b/installers/install.sh index 9d962027..8d1b1e6a 100755 --- a/installers/install.sh +++ b/installers/install.sh @@ -84,7 +84,7 @@ fi # IMPORTANT: Update this version when releasing new versions! # This hardcoded version is used for standalone installations (curl | bash) # For git installations, VERSION file is read if available -CCS_VERSION="5.3.0" +CCS_VERSION="5.4.0-beta.3" # Try to read VERSION file for git installations if [[ -f "$SCRIPT_DIR/VERSION" ]]; then diff --git a/package.json b/package.json index 784fab47..b58e7ce6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.3.0", + "version": "5.4.0-beta.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", @@ -70,7 +70,11 @@ "postinstall": "node scripts/postinstall.js" }, "dependencies": { + "boxen": "^8.0.1", + "chalk": "^5.6.2", "cli-table3": "^0.6.5", + "gradient-string": "^3.0.0", + "listr2": "^9.0.5", "ora": "^9.0.0" }, "devDependencies": { diff --git a/scripts/postinstall.js b/scripts/postinstall.js index a2466ff9..c761c920 100755 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -125,12 +125,14 @@ function createConfigFiles() { // runs `ccs gemini` or `ccs codex` for first time (requires OAuth auth first) const configPath = path.join(ccsDir, 'config.json'); if (!fs.existsSync(configPath)) { + // NOTE: No 'default' entry - when no profile specified, CCS passes through + // to Claude's native auth without --settings flag. This prevents env var + // pollution from affecting the default profile. const config = { profiles: { glm: '~/.ccs/glm.settings.json', glmt: '~/.ccs/glmt.settings.json', - kimi: '~/.ccs/kimi.settings.json', - default: '~/.claude/settings.json' + kimi: '~/.ccs/kimi.settings.json' } }; @@ -141,23 +143,38 @@ function createConfigFiles() { console.log('[OK] Created config: ~/.ccs/config.json'); } else { - // Update existing config with glmt if missing (migration for v3.x users) + // Update existing config (migration for older versions) const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); // Ensure profiles object exists if (!config.profiles) { config.profiles = {}; } let configUpdated = false; + + // Migration: Add glmt if missing (v3.x) if (!config.profiles.glmt) { config.profiles.glmt = '~/.ccs/glmt.settings.json'; configUpdated = true; } + + // Migration: Remove 'default' entry pointing to ~/.claude/settings.json (v5.4.0) + // This entry caused the default profile to pass --settings flag, which could + // pick up stale env vars (ANTHROPIC_BASE_URL) from previous profile sessions. + // Fix: Let CCS pass through to Claude's native auth without --settings flag. + if (config.profiles.default === '~/.claude/settings.json') { + delete config.profiles.default; + configUpdated = true; + console.log('[OK] Removed legacy default profile (now uses native Claude auth)'); + } + // NOTE: gemini/codex profiles added on-demand, not during migration if (configUpdated) { const tmpPath = `${configPath}.tmp`; fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); fs.renameSync(tmpPath, configPath); - console.log('[OK] Updated config with glmt profile'); + if (!config.profiles.glmt) { + console.log('[OK] Updated config with glmt profile'); + } } else { console.log('[OK] Config exists: ~/.ccs/config.json (preserved)'); } diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index 1b671e27..2a1d4168 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -13,7 +13,19 @@ import * as fs from 'fs'; import * as path from 'path'; import ProfileRegistry from './profile-registry'; import { InstanceManager } from '../management/instance-manager'; -import { colored } from '../utils/helpers'; +import { + initUI, + header, + subheader, + color, + dim, + ok, + fail, + warn, + info, + table, + infoBox, +} from '../utils/ui'; import { detectClaudeCli } from '../utils/claude-detector'; import { InteractivePrompt } from '../utils/prompt'; import packageJson from '../../package.json'; @@ -57,56 +69,54 @@ class AuthCommands { /** * Show help for auth commands */ - showHelp(): void { - console.log(colored('CCS Concurrent Account Management', 'bold')); + async showHelp(): Promise { + await initUI(); + + console.log(header('CCS Concurrent Account Management')); console.log(''); - console.log(colored('Usage:', 'cyan')); - console.log(` ${colored('ccs auth', 'yellow')} [options]`); + console.log(subheader('Usage')); + console.log(` ${color('ccs auth', 'command')} [options]`); console.log(''); - console.log(colored('Commands:', 'cyan')); - console.log(` ${colored('create ', 'yellow')} Create new profile and login`); - console.log(` ${colored('list', 'yellow')} List all saved profiles`); - console.log(` ${colored('show ', 'yellow')} Show profile details`); - console.log(` ${colored('remove ', 'yellow')} Remove saved profile`); - console.log(` ${colored('default ', 'yellow')} Set default profile`); + console.log(subheader('Commands')); + console.log(` ${color('create ', 'command')} Create new profile and login`); + console.log(` ${color('list', 'command')} List all saved profiles`); + console.log(` ${color('show ', 'command')} Show profile details`); + console.log(` ${color('remove ', 'command')} Remove saved profile`); + console.log(` ${color('default ', 'command')} Set default profile`); console.log(''); - console.log(colored('Examples:', 'cyan')); + console.log(subheader('Examples')); + console.log(` ${dim('# Create & login to work profile')}`); + console.log(` ${color('ccs auth create work', 'command')}`); + console.log(''); + console.log(` ${dim('# Set work as default')}`); + console.log(` ${color('ccs auth default work', 'command')}`); + console.log(''); + console.log(` ${dim('# List all profiles')}`); + console.log(` ${color('ccs auth list', 'command')}`); + console.log(''); + console.log(` ${dim('# Use work profile')}`); + console.log(` ${color('ccs work "review code"', 'command')}`); + console.log(''); + console.log(subheader('Options')); console.log( - ` ${colored('ccs auth create work', 'yellow')} # Create & login to work profile` + ` ${color('--force', 'command')} Allow overwriting existing profile (create)` ); console.log( - ` ${colored('ccs auth default work', 'yellow')} # Set work as default` + ` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)` ); console.log( - ` ${colored('ccs auth list', 'yellow')} # List all profiles` + ` ${color('--json', 'command')} Output in JSON format (list, show)` ); console.log( - ` ${colored('ccs work "review code"', 'yellow')} # Use work profile` - ); - console.log( - ` ${colored('ccs "review code"', 'yellow')} # Use default profile` + ` ${color('--verbose', 'command')} Show additional details (list)` ); console.log(''); - console.log(colored('Options:', 'cyan')); + console.log(subheader('Note')); console.log( - ` ${colored('--force', 'yellow')} Allow overwriting existing profile (create)` + ` By default, ${color('ccs', 'command')} uses Claude CLI defaults from ~/.claude/` ); console.log( - ` ${colored('--yes, -y', 'yellow')} Skip confirmation prompts (remove)` - ); - console.log( - ` ${colored('--json', 'yellow')} Output in JSON format (list, show)` - ); - console.log( - ` ${colored('--verbose', 'yellow')} Show additional details (list)` - ); - console.log(''); - console.log(colored('Note:', 'cyan')); - console.log( - ` By default, ${colored('ccs', 'yellow')} uses Claude CLI defaults from ~/.claude/` - ); - console.log( - ` Use ${colored('ccs auth default ', 'yellow')} to change the default profile.` + ` Use ${color('ccs auth default ', 'command')} to change the default profile.` ); console.log(''); } @@ -129,28 +139,29 @@ class AuthCommands { * Create new profile and prompt for login */ async handleCreate(args: string[]): Promise { + await initUI(); const { profileName, force } = this.parseArgs(args); if (!profileName) { - console.error('[X] Profile name is required'); + console.log(fail('Profile name is required')); console.log(''); - console.log(`Usage: ${colored('ccs auth create [--force]', 'yellow')}`); + console.log(`Usage: ${color('ccs auth create [--force]', 'command')}`); console.log(''); console.log('Example:'); - console.log(` ${colored('ccs auth create work', 'yellow')}`); + console.log(` ${color('ccs auth create work', 'command')}`); process.exit(1); } // Check if profile already exists if (!force && this.registry.hasProfile(profileName)) { - console.error(`[X] Profile already exists: ${profileName}`); - console.log(` Use ${colored('--force', 'yellow')} to overwrite`); + console.log(fail(`Profile already exists: ${profileName}`)); + console.log(` Use ${color('--force', 'command')} to overwrite`); process.exit(1); } try { // Create instance directory - console.log(`[i] Creating profile: ${profileName}`); + console.log(info(`Creating profile: ${profileName}`)); const instancePath = this.instanceMgr.ensureInstance(profileName); // Create/update profile entry @@ -164,19 +175,19 @@ class AuthCommands { }); } - console.log(`[i] Instance directory: ${instancePath}`); + console.log(info(`Instance directory: ${instancePath}`)); console.log(''); - console.log(colored('[i] Starting Claude in isolated instance...', 'yellow')); - console.log(colored('[i] You will be prompted to login with your account.', 'yellow')); + console.log(warn('Starting Claude in isolated instance...')); + console.log(warn('You will be prompted to login with your account.')); console.log(''); // Detect Claude CLI const claudeCli = detectClaudeCli(); if (!claudeCli) { - console.error('[X] Claude CLI not found'); + console.log(fail('Claude CLI not found')); console.log(''); console.log('Please install Claude CLI first:'); - console.log(' https://claude.ai/download'); + console.log(` ${color('https://claude.ai/download', 'path')}`); process.exit(1); } @@ -189,45 +200,63 @@ class AuthCommands { child.on('exit', (code: number | null) => { if (code === 0) { console.log(''); - console.log(colored('[OK] Profile created successfully', 'green')); - console.log(''); - console.log(` Profile: ${profileName}`); - console.log(` Instance: ${instancePath}`); - console.log(''); - console.log('Usage:'); console.log( - ` ${colored(`ccs ${profileName} "your prompt here"`, 'yellow')} # Use this specific profile` + infoBox( + `Profile: ${profileName}\n` + `Instance: ${instancePath}\n` + `Type: account`, + 'Profile Created' + ) ); console.log(''); + console.log(header('Usage')); + console.log(` ${color(`ccs ${profileName} "your prompt here"`, 'command')}`); + console.log(''); console.log('To set as default (so you can use just "ccs"):'); - console.log(` ${colored(`ccs auth default ${profileName}`, 'yellow')}`); + console.log(` ${color(`ccs auth default ${profileName}`, 'command')}`); console.log(''); process.exit(0); } else { console.log(''); - console.error('[X] Login failed or cancelled'); + console.log(fail('Login failed or cancelled')); console.log(''); console.log('To retry:'); - console.log(` ${colored(`ccs auth create ${profileName} --force`, 'yellow')}`); + console.log(` ${color(`ccs auth create ${profileName} --force`, 'command')}`); console.log(''); process.exit(1); } }); child.on('error', (err: Error) => { - console.error(`[X] Failed to execute Claude CLI: ${err.message}`); + console.log(fail(`Failed to execute Claude CLI: ${err.message}`)); process.exit(1); }); } catch (error) { - console.error(`[X] Failed to create profile: ${(error as Error).message}`); + console.log(fail(`Failed to create profile: ${(error as Error).message}`)); process.exit(1); } } + /** + * Format relative time (e.g., "2h ago", "1d ago") + */ + private formatRelativeTime(date: Date): string { + const now = Date.now(); + const diff = now - date.getTime(); + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + if (minutes > 0) return `${minutes}m ago`; + return 'just now'; + } + /** * List all saved profiles */ async handleList(args: string[]): Promise { + await initUI(); const { verbose, json } = this.parseArgs(args); try { @@ -260,20 +289,18 @@ class AuthCommands { // Human-readable output if (profileNames.length === 0) { - console.log(colored('No account profiles found', 'yellow')); + console.log(warn('No account profiles found')); console.log(''); console.log('To create your first profile:'); - console.log( - ` ${colored('ccs auth create ', 'yellow')} # Create and login to profile` - ); + console.log(` ${color('ccs auth create ', 'command')}`); console.log(''); console.log('Example:'); - console.log(` ${colored('ccs auth create work', 'yellow')}`); + console.log(` ${color('ccs auth create work', 'command')}`); console.log(''); return; } - console.log(colored('Saved Account Profiles:', 'bold')); + console.log(header('Saved Account Profiles')); console.log(''); // Sort by last_used (descending), then alphabetically @@ -296,31 +323,50 @@ class AuthCommands { return a.localeCompare(b); }); - sorted.forEach((name) => { + // Build table rows + const rows: string[][] = sorted.map((name) => { const profile = profiles[name]; const isDefault = name === defaultProfile; - const indicator = isDefault ? colored('[*]', 'green') : '[ ]'; - console.log( - `${indicator} ${colored(name, 'cyan')}${isDefault ? colored(' (default)', 'green') : ''}` - ); + // Status column + const status = isDefault ? color('[OK] default', 'success') : color('[OK]', 'success'); - console.log(` Type: ${profile.type || 'account'}`); - - if (verbose) { - console.log(` Created: ${new Date(profile.created).toLocaleString()}`); - if (profile.last_used) { - console.log(` Last used: ${new Date(profile.last_used).toLocaleString()}`); - } + // Last used column + let lastUsed = '-'; + if (profile.last_used) { + lastUsed = this.formatRelativeTime(new Date(profile.last_used)); } - console.log(''); + const row = [ + color(name, isDefault ? 'primary' : 'info'), + profile.type || 'account', + status, + ]; + + if (verbose) { + row.push(lastUsed); + } + + return row; }); - console.log(`Total profiles: ${profileNames.length}`); + // Headers + const headers = verbose + ? ['Profile', 'Type', 'Status', 'Last Used'] + : ['Profile', 'Type', 'Status']; + + // Print table + console.log( + table(rows, { + head: headers, + colWidths: verbose ? [15, 12, 15, 12] : [15, 12, 15], + }) + ); + console.log(''); + console.log(dim(`Total: ${profileNames.length} profile(s)`)); console.log(''); } catch (error) { - console.error(`[X] Failed to list profiles: ${(error as Error).message}`); + console.log(fail(`Failed to list profiles: ${(error as Error).message}`)); process.exit(1); } } @@ -329,12 +375,13 @@ class AuthCommands { * Show details for a specific profile */ async handleShow(args: string[]): Promise { + await initUI(); const { profileName, json } = this.parseArgs(args); if (!profileName) { - console.error('[X] Profile name is required'); + console.log(fail('Profile name is required')); console.log(''); - console.log(`Usage: ${colored('ccs auth show [--json]', 'yellow')}`); + console.log(`Usage: ${color('ccs auth show [--json]', 'command')}`); process.exit(1); } @@ -372,22 +419,27 @@ class AuthCommands { } // Human-readable output - console.log(colored(`Profile: ${profileName}`, 'bold')); + const defaultBadge = isDefault ? color(' (default)', 'success') : ''; + console.log(header(`Profile: ${profileName}${defaultBadge}`)); console.log(''); - console.log(` Type: ${profile.type || 'account'}`); - console.log(` Default: ${isDefault ? 'Yes' : 'No'}`); - console.log(` Instance: ${instancePath}`); - console.log(` Created: ${new Date(profile.created).toLocaleString()}`); - if (profile.last_used) { - console.log(` Last used: ${new Date(profile.last_used).toLocaleString()}`); - } else { - console.log(` Last used: Never`); - } + // Details table + const details = [ + ['Type', profile.type || 'account'], + ['Instance', instancePath], + ['Created', new Date(profile.created).toLocaleString()], + ['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'], + ['Sessions', `${sessionCount}`], + ]; + console.log( + table(details, { + colWidths: [15, 45], + }) + ); console.log(''); } catch (error) { - console.error(`[X] ${(error as Error).message}`); + console.log(fail((error as Error).message)); process.exit(1); } } @@ -396,17 +448,18 @@ class AuthCommands { * Remove a saved profile */ async handleRemove(args: string[]): Promise { + await initUI(); const { profileName, yes } = this.parseArgs(args); if (!profileName) { - console.error('[X] Profile name is required'); + console.log(fail('Profile name is required')); console.log(''); - console.log(`Usage: ${colored('ccs auth remove [--yes]', 'yellow')}`); + console.log(`Usage: ${color('ccs auth remove [--yes]', 'command')}`); process.exit(1); } if (!this.registry.hasProfile(profileName)) { - console.error(`[X] Profile not found: ${profileName}`); + console.log(fail(`Profile not found: ${profileName}`)); process.exit(1); } @@ -427,7 +480,7 @@ class AuthCommands { // Display impact console.log(''); - console.log(`Profile '${colored(profileName, 'cyan')}' will be permanently deleted.`); + console.log(`Profile '${color(profileName, 'command')}' will be permanently deleted.`); console.log(` Instance path: ${instancePath}`); console.log(` Sessions: ${sessionCount} conversation${sessionCount !== 1 ? 's' : ''}`); console.log(''); @@ -441,7 +494,7 @@ class AuthCommands { )); if (!confirmed) { - console.log('[i] Cancelled'); + console.log(info('Cancelled')); process.exit(0); } @@ -451,11 +504,10 @@ class AuthCommands { // Delete profile this.registry.deleteProfile(profileName); - console.log(colored('[OK] Profile removed successfully', 'green')); - console.log(` Profile: ${profileName}`); + console.log(ok(`Profile removed: ${profileName}`)); console.log(''); } catch (error) { - console.error(`[X] Failed to remove profile: ${(error as Error).message}`); + console.log(fail(`Failed to remove profile: ${(error as Error).message}`)); process.exit(1); } } @@ -464,26 +516,28 @@ class AuthCommands { * Set default profile */ async handleDefault(args: string[]): Promise { + await initUI(); const { profileName } = this.parseArgs(args); if (!profileName) { - console.error('[X] Profile name is required'); + console.log(fail('Profile name is required')); console.log(''); - console.log(`Usage: ${colored('ccs auth default ', 'yellow')}`); + console.log(`Usage: ${color('ccs auth default ', 'command')}`); process.exit(1); } try { this.registry.setDefaultProfile(profileName); - console.log(colored('[OK] Default profile set', 'green')); - console.log(` Profile: ${profileName}`); + console.log(ok(`Default profile set: ${profileName}`)); console.log(''); console.log('Now you can use:'); - console.log(` ${colored('ccs "your prompt"', 'yellow')} # Uses ${profileName} profile`); + console.log( + ` ${color('ccs "your prompt"', 'command')} ${dim(`# Uses ${profileName} profile`)}` + ); console.log(''); } catch (error) { - console.error(`[X] ${(error as Error).message}`); + console.log(fail((error as Error).message)); process.exit(1); } } @@ -493,7 +547,7 @@ class AuthCommands { */ async route(args: string[]): Promise { if (args.length === 0 || args[0] === '--help' || args[0] === '-h' || args[0] === 'help') { - this.showHelp(); + await this.showHelp(); return; } @@ -507,8 +561,9 @@ class AuthCommands { case 'save': // Deprecated - redirect to create - console.log(colored('[!] Command "save" is deprecated', 'yellow')); - console.log(` Use: ${colored('ccs auth create ', 'yellow')} instead`); + await initUI(); + console.log(warn('Command "save" is deprecated')); + console.log(` Use: ${color('ccs auth create ', 'command')} instead`); console.log(''); await this.handleCreate(commandArgs); break; @@ -530,26 +585,29 @@ class AuthCommands { break; case 'current': - console.log(colored('[!] Command "current" has been removed', 'yellow')); + await initUI(); + console.log(warn('Command "current" has been removed')); console.log(''); console.log('Each profile has its own login in an isolated instance.'); - console.log('Use "ccs auth list" to see all profiles.'); + console.log(`Use ${color('ccs auth list', 'command')} to see all profiles.`); console.log(''); break; case 'cleanup': - console.log(colored('[!] Command "cleanup" has been removed', 'yellow')); + await initUI(); + console.log(warn('Command "cleanup" has been removed')); console.log(''); console.log('No cleanup needed - no separate vault files.'); - console.log('Use "ccs auth list" to see all profiles.'); + console.log(`Use ${color('ccs auth list', 'command')} to see all profiles.`); console.log(''); break; default: - console.error(`[X] Unknown command: ${command}`); + await initUI(); + console.log(fail(`Unknown command: ${command}`)); console.log(''); console.log('Run for help:'); - console.log(` ${colored('ccs auth --help', 'yellow')}`); + console.log(` ${color('ccs auth --help', 'command')}`); process.exit(1); } } diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 2886090d..eab38008 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -178,10 +178,21 @@ class ProfileDetector { const config = this.readConfig(); if (config.profiles && config.profiles['default']) { + const settingsPath = config.profiles['default']; + // Safety net: If default points to ~/.claude/settings.json, treat as pass-through + // to avoid loading stale env vars from previous profile sessions (issue #37). + // The ~/.claude/settings.json is Claude's native config - let Claude handle it. + if (settingsPath.includes('.claude') && settingsPath.endsWith('settings.json')) { + return { + type: 'default', + name: 'default', + message: 'Using native Claude auth (no custom env vars)', + }; + } return { type: 'settings', name: 'default', - settingsPath: config.profiles['default'], + settingsPath, }; } diff --git a/src/ccs.ts b/src/ccs.ts index 5d228202..a8b7778b 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -204,7 +204,7 @@ async function main(): Promise { // Special case: help command if (firstArg === '--help' || firstArg === '-h' || firstArg === 'help') { - handleHelpCommand(); + await handleHelpCommand(); return; } @@ -253,10 +253,10 @@ async function main(): Promise { return; } - // Special case: profile command - if (firstArg === 'profile') { - const { handleProfileCommand } = await import('./commands/profile-command'); - await handleProfileCommand(args.slice(1)); + // Special case: api command (manages API profiles) + if (firstArg === 'api') { + const { handleApiCommand } = await import('./commands/api-command'); + await handleApiCommand(args.slice(1)); return; } @@ -284,7 +284,7 @@ async function main(): Promise { // Detect Claude CLI first (needed for all paths) const claudeCli = detectClaudeCli(); if (!claudeCli) { - ErrorManager.showClaudeNotFound(); + await ErrorManager.showClaudeNotFound(); process.exit(1); } @@ -341,7 +341,7 @@ async function main(): Promise { // Check if this is a profile not found error with suggestions if (err.profileName && err.availableProfiles !== undefined) { const allProfiles = err.availableProfiles.split('\n'); - ErrorManager.showProfileNotFound(err.profileName, allProfiles, err.suggestions); + await ErrorManager.showProfileNotFound(err.profileName, allProfiles, err.suggestions); } else { console.error(`[X] ${err.message}`); } diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index 11354a1e..9c6cde6c 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -127,7 +127,7 @@ api-keys: # OAuth tokens stored in auth/ directory # CLIProxyAPI auto-discovers auth files in subdirectories -auth-dir: "${authDir}" +auth-dir: "${authDir.replace(/\\/g, '/')}" # All providers configured - routes by model name # No provider-specific sections needed - OAuth auth files provide credentials diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts new file mode 100644 index 00000000..ab7c9fff --- /dev/null +++ b/src/commands/api-command.ts @@ -0,0 +1,485 @@ +/** + * API Command Handler + * + * Manages CCS API profiles for custom API providers. + * Commands: create, list, remove + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { + initUI, + header, + subheader, + color, + dim, + ok, + fail, + warn, + info, + table, + infoBox, +} from '../utils/ui'; +import { InteractivePrompt } from '../utils/prompt'; +import { getCcsDir, getConfigPath, loadConfig } from '../utils/config-manager'; + +interface ApiCommandArgs { + name?: string; + baseUrl?: string; + apiKey?: string; + model?: string; + force?: boolean; + yes?: boolean; +} + +/** + * Parse command line arguments for api commands + */ +function parseArgs(args: string[]): ApiCommandArgs { + const result: ApiCommandArgs = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--base-url' && args[i + 1]) { + result.baseUrl = args[++i]; + } else if (arg === '--api-key' && args[i + 1]) { + result.apiKey = args[++i]; + } else if (arg === '--model' && args[i + 1]) { + result.model = args[++i]; + } else if (arg === '--force') { + result.force = true; + } else if (arg === '--yes' || arg === '-y') { + result.yes = true; + } else if (!arg.startsWith('-') && !result.name) { + result.name = arg; + } + } + + return result; +} + +/** + * Validate API profile name + */ +function validateApiName(name: string): string | null { + if (!name) { + return 'API name is required'; + } + if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) { + return 'API name must start with letter, contain only letters, numbers, dot, dash, underscore'; + } + if (name.length > 32) { + return 'API name must be 32 characters or less'; + } + // Reserved names + const reserved = ['default', 'auth', 'api', 'doctor', 'sync', 'update', 'help', 'version']; + if (reserved.includes(name.toLowerCase())) { + return `'${name}' is a reserved name`; + } + return null; +} + +/** + * Validate URL format + */ +function validateUrl(url: string): string | null { + if (!url) { + return 'Base URL is required'; + } + try { + new URL(url); + return null; + } catch { + return 'Invalid URL format (must include protocol, e.g., https://)'; + } +} + +/** + * Check if API profile already exists in config.json + */ +function apiExists(name: string): boolean { + try { + const config = loadConfig(); + return name in config.profiles; + } catch { + return false; + } +} + +/** + * Create settings.json file for API profile + */ +function createSettingsFile(name: string, baseUrl: string, apiKey: string, model: string): string { + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${name}.settings.json`); + + const settings = { + env: { + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_AUTH_TOKEN: apiKey, + ANTHROPIC_MODEL: model, + }, + }; + + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); + return settingsPath; +} + +/** + * Update config.json with new API profile + */ +function updateConfig(name: string, _settingsPath: string): void { + const configPath = getConfigPath(); + const ccsDir = getCcsDir(); + + // Read existing config or create new + let config: { profiles: Record; cliproxy?: Record }; + try { + config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch { + config = { profiles: {} }; + } + + // Use relative path with ~ for portability + const relativePath = `~/.ccs/${name}.settings.json`; + config.profiles[name] = relativePath; + + // Ensure directory exists + if (!fs.existsSync(ccsDir)) { + fs.mkdirSync(ccsDir, { recursive: true }); + } + + // Write config atomically (write to temp, then rename) + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); +} + +/** + * Handle 'ccs api create' command + */ +async function handleCreate(args: string[]): Promise { + await initUI(); + const parsedArgs = parseArgs(args); + + console.log(header('Create API Profile')); + console.log(''); + + // Step 1: API name + let name = parsedArgs.name; + if (!name) { + name = await InteractivePrompt.input('API name', { + validate: validateApiName, + }); + } else { + const error = validateApiName(name); + if (error) { + console.log(fail(error)); + process.exit(1); + } + } + + // Check if exists + if (apiExists(name) && !parsedArgs.force) { + console.log(fail(`API '${name}' already exists`)); + console.log(` Use ${color('--force', 'command')} to overwrite`); + process.exit(1); + } + + // Step 2: Base URL + let baseUrl = parsedArgs.baseUrl; + if (!baseUrl) { + baseUrl = await InteractivePrompt.input('API Base URL (e.g., https://api.example.com)', { + validate: validateUrl, + }); + } else { + const error = validateUrl(baseUrl); + if (error) { + console.log(fail(error)); + process.exit(1); + } + } + + // Step 3: API Key + let apiKey = parsedArgs.apiKey; + if (!apiKey) { + apiKey = await InteractivePrompt.password('API Key'); + if (!apiKey) { + console.log(fail('API key is required')); + process.exit(1); + } + } + + // Step 4: Model (optional) + const defaultModel = 'claude-sonnet-4-5-20250929'; + let model = parsedArgs.model; + if (!model && !parsedArgs.yes) { + model = await InteractivePrompt.input('Default model', { + default: defaultModel, + }); + } + model = model || defaultModel; + + // Create files + console.log(''); + console.log(info('Creating API profile...')); + + try { + const settingsPath = createSettingsFile(name, baseUrl, apiKey, model); + updateConfig(name, settingsPath); + + console.log(''); + console.log( + infoBox( + `API: ${name}\n` + + `Settings: ~/.ccs/${name}.settings.json\n` + + `Base URL: ${baseUrl}\n` + + `Model: ${model}`, + 'API Profile Created' + ) + ); + console.log(''); + console.log(header('Usage')); + console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`); + console.log(''); + } catch (error) { + console.log(fail(`Failed to create API profile: ${(error as Error).message}`)); + process.exit(1); + } +} + +/** + * Check if API profile has real API key (not placeholder) + */ +function isApiConfigured(apiName: string): boolean { + try { + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, `${apiName}.settings.json`); + if (!fs.existsSync(settingsPath)) return false; + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || ''; + // Check if it's a placeholder or empty + return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-'); + } catch { + return false; + } +} + +/** + * Handle 'ccs api list' command + */ +async function handleList(): Promise { + await initUI(); + + console.log(header('CCS API Profiles')); + console.log(''); + + try { + const config = loadConfig(); + const apis = Object.keys(config.profiles); + + if (apis.length === 0) { + console.log(warn('No API profiles configured')); + console.log(''); + console.log('To create an API profile:'); + console.log(` ${color('ccs api create', 'command')}`); + console.log(''); + return; + } + + // Build table data with status indicators + const rows: string[][] = apis.map((name) => { + const settingsPath = config.profiles[name]; + const status = isApiConfigured(name) ? color('[OK]', 'success') : color('[!]', 'warning'); + + return [name, settingsPath, status]; + }); + + // Print table + console.log( + table(rows, { + head: ['API', 'Settings File', 'Status'], + colWidths: [15, 35, 10], + }) + ); + console.log(''); + + // Show CLIProxy variants if any + if (config.cliproxy && Object.keys(config.cliproxy).length > 0) { + console.log(subheader('CLIProxy Variants')); + const cliproxyRows = Object.entries(config.cliproxy).map(([name, v]) => { + const variant = v as { provider: string; settings: string }; + return [name, variant.provider, variant.settings]; + }); + + console.log( + table(cliproxyRows, { + head: ['Variant', 'Provider', 'Settings'], + colWidths: [15, 15, 30], + }) + ); + console.log(''); + } + + console.log(dim(`Total: ${apis.length} API profile(s)`)); + console.log(''); + } catch (error) { + console.log(fail(`Failed to list API profiles: ${(error as Error).message}`)); + process.exit(1); + } +} + +/** + * Handle 'ccs api remove' command + */ +async function handleRemove(args: string[]): Promise { + await initUI(); + const parsedArgs = parseArgs(args); + + // Load config first to get available APIs + let config: { profiles: Record; cliproxy?: Record }; + try { + config = loadConfig(); + } catch { + console.log(fail('Failed to load config')); + process.exit(1); + } + + const apis = Object.keys(config.profiles); + if (apis.length === 0) { + console.log(warn('No API profiles to remove')); + process.exit(0); + } + + // Interactive API selection if not provided + let name = parsedArgs.name; + if (!name) { + console.log(header('Remove API Profile')); + console.log(''); + console.log('Available APIs:'); + apis.forEach((p, i) => console.log(` ${i + 1}. ${p}`)); + console.log(''); + + name = await InteractivePrompt.input('API name to remove', { + validate: (val) => { + if (!val) return 'API name is required'; + if (!apis.includes(val)) return `API '${val}' not found`; + return null; + }, + }); + } + + if (!(name in config.profiles)) { + console.log(fail(`API '${name}' not found`)); + console.log(''); + console.log('Available APIs:'); + apis.forEach((p) => console.log(` - ${p}`)); + process.exit(1); + } + + const settingsPath = config.profiles[name]; + const expandedPath = path.join(getCcsDir(), `${name}.settings.json`); + + // Confirm deletion + console.log(''); + console.log(`API '${color(name, 'command')}' will be removed.`); + console.log(` Settings: ${settingsPath}`); + console.log(''); + + const confirmed = + parsedArgs.yes || + (await InteractivePrompt.confirm('Delete this API profile?', { default: false })); + + if (!confirmed) { + console.log(info('Cancelled')); + process.exit(0); + } + + // Remove from config.json + delete config.profiles[name]; + const configPath = getConfigPath(); + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); + + // Remove settings file if it exists + if (fs.existsSync(expandedPath)) { + fs.unlinkSync(expandedPath); + } + + console.log(ok(`API profile removed: ${name}`)); + console.log(''); +} + +/** + * Show help for api commands + */ +async function showHelp(): Promise { + await initUI(); + + console.log(header('CCS API Management')); + console.log(''); + console.log(subheader('Usage')); + console.log(` ${color('ccs api', 'command')} [options]`); + console.log(''); + console.log(subheader('Commands')); + console.log(` ${color('create [name]', 'command')} Create new API profile (interactive)`); + console.log(` ${color('list', 'command')} List all API profiles`); + console.log(` ${color('remove ', 'command')} Remove an API profile`); + console.log(''); + console.log(subheader('Options')); + console.log(` ${color('--base-url ', 'command')} API base URL (create)`); + console.log(` ${color('--api-key ', 'command')} API key (create)`); + console.log(` ${color('--model ', 'command')} Default model (create)`); + console.log(` ${color('--force', 'command')} Overwrite existing (create)`); + console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`); + console.log(''); + console.log(subheader('Examples')); + console.log(` ${dim('# Interactive wizard')}`); + console.log(` ${color('ccs api create', 'command')}`); + console.log(''); + console.log(` ${dim('# Create with name')}`); + console.log(` ${color('ccs api create myapi', 'command')}`); + console.log(''); + console.log(` ${dim('# Remove API profile')}`); + console.log(` ${color('ccs api remove myapi', 'command')}`); + console.log(''); + console.log(` ${dim('# Show all API profiles')}`); + console.log(` ${color('ccs api list', 'command')}`); + console.log(''); +} + +/** + * Main api command router + */ +export async function handleApiCommand(args: string[]): Promise { + const command = args[0]; + + if (!command || command === '--help' || command === '-h' || command === 'help') { + await showHelp(); + return; + } + + switch (command) { + case 'create': + await handleCreate(args.slice(1)); + break; + case 'list': + await handleList(); + break; + case 'remove': + case 'delete': + case 'rm': + await handleRemove(args.slice(1)); + break; + default: + await initUI(); + console.log(fail(`Unknown command: ${command}`)); + console.log(''); + console.log('Run for help:'); + console.log(` ${color('ccs api --help', 'command')}`); + process.exit(1); + } +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 370e0aa6..edac032a 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -1,142 +1,239 @@ -import { colored } from '../utils/helpers'; +import { initUI, box, color, dim, sectionHeader, subheader } from '../utils/ui'; + +// Version is read from VERSION file during build +const VERSION = '5.3.0'; + +/** + * Print a major section with ═══ borders (only for 3 main sections) + * Format: + * ═══ TITLE ═══ + * Subtitle line 1 + * Subtitle line 2 + * + * command Description + */ +function printMajorSection(title: string, subtitles: string[], items: [string, string][]): void { + // Section header with ═══ borders + console.log(sectionHeader(title)); + + // Subtitles on separate lines (dim) + for (const subtitle of subtitles) { + console.log(` ${dim(subtitle)}`); + } + + // Empty line before items + console.log(''); + + // Calculate max command length for alignment + const maxCmdLen = Math.max(...items.map(([cmd]) => cmd.length)); + + for (const [cmd, desc] of items) { + const paddedCmd = cmd.padEnd(maxCmdLen + 2); + console.log(` ${color(paddedCmd, 'command')} ${desc}`); + } + + // Extra spacing after section + console.log(''); +} + +/** + * Print a sub-section with colored title + * Format: + * Title (context): + * command Description + */ +function printSubSection(title: string, items: [string, string][]): void { + // Sub-section header (colored, no borders) + console.log(subheader(`${title}:`)); + + // Calculate max command length for alignment + const maxCmdLen = Math.max(...items.map(([cmd]) => cmd.length)); + + for (const [cmd, desc] of items) { + const paddedCmd = cmd.padEnd(maxCmdLen + 2); + console.log(` ${color(paddedCmd, 'command')} ${desc}`); + } + + // Spacing after section + console.log(''); +} + +/** + * Print a config/paths section + * Format: + * Title: + * Label: path + */ +function printConfigSection(title: string, items: [string, string][]): void { + console.log(subheader(`${title}:`)); + + // Calculate max label length for alignment + const maxLabelLen = Math.max(...items.map(([label]) => label.length)); + + for (const [label, path] of items) { + const paddedLabel = label.padEnd(maxLabelLen); + console.log(` ${paddedLabel} ${color(path, 'path')}`); + } + + console.log(''); +} /** * Display comprehensive help information for CCS (Claude Code Switch) */ -export function handleHelpCommand(): void { - console.log(colored('CCS (Claude Code Switch) - Profile switching for Claude CLI', 'bold')); +export async function handleHelpCommand(): Promise { + // Initialize UI (if not already) + await initUI(); + + // Hero box with ASCII art logo + // Each letter: C=╔═╗/║ /╚═╝, C=╔═╗/║ /╚═╝, S=╔═╗/╚═╗/╚═╝ + const logo = ` +╔═╗ ╔═╗ ╔═╗ +║ ║ ╚═╗ v${VERSION} +╚═╝ ╚═╝ ╚═╝ + +Claude Code Profile & Model Switcher`.trim(); + + console.log( + box(logo, { + padding: 1, + borderStyle: 'round', + titleAlignment: 'center', + }) + ); console.log(''); - console.log(colored('Usage:', 'cyan')); - console.log(` ${colored('ccs', 'yellow')} [profile] [claude-args...]`); - console.log(` ${colored('ccs', 'yellow')} [flags]`); + // Usage section + console.log(subheader('Usage:')); + console.log(` ${color('ccs', 'command')} [profile] [claude-args...]`); + console.log(` ${color('ccs', 'command')} [flags]`); console.log(''); // ═══════════════════════════════════════════════════════════════════════════ - // SECTION 1: API KEY MODELS + PROFILE MANAGEMENT + // MAJOR SECTION 1: API Key Profiles // ═══════════════════════════════════════════════════════════════════════════ - console.log(colored('═══ API Key Profiles ═══', 'cyanBold')); - console.log(' Configure in ~/.ccs/*.settings.json'); - console.log(''); - console.log(` ${colored('ccs', 'yellow')} Use default Claude account`); - console.log(` ${colored('ccs glm', 'yellow')} GLM 4.6 (API key required)`); - console.log(` ${colored('ccs glmt', 'yellow')} GLM with thinking mode`); - console.log(` ${colored('ccs kimi', 'yellow')} Kimi for Coding (API key)`); - console.log(` ${colored('ccs profile create', 'yellow')} Create custom API profile`); - console.log(` ${colored('ccs profile remove', 'yellow')} Remove a profile`); - console.log(` ${colored('ccs profile list', 'yellow')} List all profiles`); - console.log(''); + printMajorSection( + 'API Key Profiles', + ['Configure in ~/.ccs/*.settings.json'], + [ + ['ccs', 'Use default Claude account'], + ['ccs glm', 'GLM 4.6 (API key required)'], + ['ccs glmt', 'GLM with thinking mode'], + ['ccs kimi', 'Kimi for Coding (API key)'], + ['', ''], // Spacer + ['ccs api create', 'Create custom API profile'], + ['ccs api remove', 'Remove an API profile'], + ['ccs api list', 'List all API profiles'], + ] + ); // ═══════════════════════════════════════════════════════════════════════════ - // SECTION 2: ACCOUNT MANAGEMENT + // MAJOR SECTION 2: Account Management // ═══════════════════════════════════════════════════════════════════════════ - console.log(colored('═══ Account Management ═══', 'cyanBold')); - console.log(' Run multiple Claude accounts concurrently'); - console.log(''); - console.log( - ` ${colored('ccs auth --help', 'yellow')} Show account management commands` + printMajorSection( + 'Account Management', + ['Run multiple Claude accounts concurrently'], + [ + ['ccs auth --help', 'Show account management commands'], + ['ccs auth create ', 'Create new account profile'], + ['ccs auth list', 'List all account profiles'], + ] ); - console.log(` ${colored('ccs auth create ', 'yellow')} Create new account profile`); - console.log(` ${colored('ccs auth list', 'yellow')} List all account profiles`); - console.log(''); // ═══════════════════════════════════════════════════════════════════════════ - // SECTION 3: CLI PROXY (OAUTH PROVIDERS) + // MAJOR SECTION 3: CLI Proxy (OAuth Providers) // ═══════════════════════════════════════════════════════════════════════════ - console.log(colored('═══ CLI Proxy (OAuth Providers) ═══', 'cyanBold')); - console.log(' Zero-config OAuth authentication via CLIProxyAPI'); - console.log(' First run: Browser opens for authentication'); - console.log(' Settings: ~/.ccs/{provider}.settings.json (created after auth)'); - console.log(''); - console.log( - ` ${colored('ccs gemini', 'yellow')} Google Gemini (gemini-2.5-pro)` + printMajorSection( + 'CLI Proxy (OAuth Providers)', + [ + 'Zero-config OAuth authentication via CLIProxyAPI', + 'First run: Browser opens for authentication', + 'Settings: ~/.ccs/{provider}.settings.json (created after auth)', + ], + [ + ['ccs gemini', 'Google Gemini (gemini-2.5-pro)'], + ['ccs codex', 'OpenAI Codex (gpt-5.1-codex-max)'], + ['ccs agy', 'Antigravity (gemini-3-pro-preview)'], + ['ccs qwen', 'Qwen Code (qwen3-coder)'], + ['', ''], // Spacer + ['ccs --auth', 'Authenticate only'], + ['ccs --logout', 'Clear authentication'], + ['ccs --headless', 'Headless auth (for SSH)'], + ['ccs codex "explain code"', 'Use with prompt'], + ] ); - console.log( - ` ${colored('ccs codex', 'yellow')} OpenAI Codex (gpt-5.1-codex-max)` - ); - console.log( - ` ${colored('ccs agy', 'yellow')} Antigravity (gemini-3-pro-preview)` - ); - console.log(` ${colored('ccs qwen', 'yellow')} Qwen Code (qwen3-coder)`); - console.log(''); - console.log(` ${colored('ccs --auth', 'yellow')} Authenticate only`); - console.log(` ${colored('ccs --logout', 'yellow')} Clear authentication`); - console.log(` ${colored('ccs --headless', 'yellow')} Headless auth (for SSH)`); - console.log(` ${colored('ccs codex "explain code"', 'yellow')} Use with prompt`); - console.log(''); // ═══════════════════════════════════════════════════════════════════════════ - // DELEGATION + // SUB-SECTIONS (simpler styling) // ═══════════════════════════════════════════════════════════════════════════ - console.log(colored('Delegation (inside Claude Code CLI):', 'cyan')); - console.log( - ` ${colored('/ccs "task"', 'yellow')} Delegate task (auto-selects profile)` - ); - console.log( - ` ${colored('/ccs --glm "task"', 'yellow')} Force GLM-4.6 for simple tasks` - ); - console.log(` ${colored('/ccs --kimi "task"', 'yellow')} Force Kimi for long context`); - console.log( - ` ${colored('/ccs:continue "follow-up"', 'yellow')} Continue last delegation session` - ); + + // Delegation + printSubSection('Delegation (inside Claude Code CLI)', [ + ['/ccs "task"', 'Delegate task (auto-selects profile)'], + ['/ccs --glm "task"', 'Force GLM-4.6 for simple tasks'], + ['/ccs --kimi "task"', 'Force Kimi for long context'], + ['/ccs:continue "follow-up"', 'Continue last delegation session'], + ]); + + // Diagnostics + printSubSection('Diagnostics', [ + ['ccs doctor', 'Run health check and diagnostics'], + ['ccs sync', 'Sync delegation commands and skills'], + ['ccs update', 'Update CCS to latest version'], + ]); + + // Flags + printSubSection('Flags', [ + ['-h, --help', 'Show this help message'], + ['-v, --version', 'Show version and installation info'], + ['-sc, --shell-completion', 'Install shell auto-completion'], + ]); + + // Configuration + printConfigSection('Configuration', [ + ['Config File:', '~/.ccs/config.json'], + ['Profiles:', '~/.ccs/profiles.json'], + ['Instances:', '~/.ccs/instances/'], + ['Settings:', '~/.ccs/*.settings.json'], + ]); + + // CLI Proxy paths + console.log(subheader('CLI Proxy:')); + console.log(` Binary: ${color('~/.ccs/cliproxy/bin/cli-proxy-api', 'path')}`); + console.log(` Config: ${color('~/.ccs/cliproxy/config.yaml', 'path')}`); + console.log(` Auth: ${color('~/.ccs/cliproxy/auth/', 'path')}`); + console.log(` ${dim('Port: 8317 (default)')}`); console.log(''); - console.log(colored('Diagnostics:', 'cyan')); + // Shared Data + console.log(subheader('Shared Data:')); + console.log(` Commands: ${color('~/.ccs/shared/commands/', 'path')}`); + console.log(` Skills: ${color('~/.ccs/shared/skills/', 'path')}`); + console.log(` Agents: ${color('~/.ccs/shared/agents/', 'path')}`); + console.log(` ${dim('Note: Symlinked across all profiles')}`); + console.log(''); + + // Examples (aligned with consistent spacing) + console.log(subheader('Examples:')); + console.log(` $ ${color('ccs', 'command')} ${dim('# Use default account')}`); console.log( - ` ${colored('ccs doctor', 'yellow')} Run health check and diagnostics` + ` $ ${color('ccs gemini', 'command')} ${dim('# OAuth (browser opens first time)')}` ); - console.log( - ` ${colored('ccs sync', 'yellow')} Sync delegation commands and skills` - ); - console.log(` ${colored('ccs update', 'yellow')} Update CCS to latest version`); + console.log(` $ ${color('ccs glm "implement API"', 'command')} ${dim('# API key model')}`); console.log(''); - console.log(colored('Flags:', 'cyan')); - console.log(` ${colored('-h, --help', 'yellow')} Show this help message`); - console.log( - ` ${colored('-v, --version', 'yellow')} Show version and installation info` - ); - console.log( - ` ${colored('-sc, --shell-completion', 'yellow')} Install shell auto-completion` - ); + // Docs link + console.log(` ${dim('Docs: https://github.com/kaitranntt/ccs')}`); console.log(''); - console.log(colored('Configuration:', 'cyan')); - console.log(' Config File: ~/.ccs/config.json'); - console.log(' Profiles: ~/.ccs/profiles.json'); - console.log(' Instances: ~/.ccs/instances/'); - console.log(' Settings: ~/.ccs/*.settings.json'); + // Uninstall + console.log(subheader('Uninstall:')); + console.log(` ${color('npm uninstall -g @kaitranntt/ccs', 'command')}`); console.log(''); - console.log(colored('CLI Proxy:', 'cyan')); - console.log(' Binary: ~/.ccs/cliproxy/bin/cli-proxy-api'); - console.log(' Config: ~/.ccs/cliproxy/config.yaml'); - console.log(' Auth: ~/.ccs/cliproxy/auth/'); - console.log(' Port: 8317 (default)'); + // License + console.log(dim('License: MIT')); console.log(''); - console.log(colored('Shared Data:', 'cyan')); - console.log(' Commands: ~/.ccs/shared/commands/'); - console.log(' Skills: ~/.ccs/shared/skills/'); - console.log(' Agents: ~/.ccs/shared/agents/'); - console.log(' Note: Symlinked across all profiles'); - console.log(''); - - console.log(colored('Examples:', 'cyan')); - console.log(` ${colored('$ ccs', 'yellow')} # Use default account`); - console.log( - ` ${colored('$ ccs gemini', 'yellow')} # OAuth (browser opens first time)` - ); - console.log(` ${colored('$ ccs glm "implement API"', 'yellow')} # API key model`); - console.log(''); - console.log(` Docs: ${colored('https://github.com/kaitranntt/ccs', 'cyan')}`); - console.log(''); - - console.log(colored('Uninstall:', 'yellow')); - console.log(' npm uninstall -g @kaitranntt/ccs'); - console.log(''); - - console.log(`${colored('License:', 'cyan')} MIT`); - process.exit(0); } diff --git a/src/commands/profile-command.ts b/src/commands/profile-command.ts deleted file mode 100644 index 0f09c50f..00000000 --- a/src/commands/profile-command.ts +++ /dev/null @@ -1,423 +0,0 @@ -/** - * Profile Command Handler - * - * Manages CCS profiles for custom API providers. - * Commands: create, list - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import { colored } from '../utils/helpers'; -import { InteractivePrompt } from '../utils/prompt'; -import { getCcsDir, getConfigPath, loadConfig } from '../utils/config-manager'; - -interface ProfileCommandArgs { - name?: string; - baseUrl?: string; - apiKey?: string; - model?: string; - force?: boolean; - yes?: boolean; -} - -/** - * Parse command line arguments for profile commands - */ -function parseArgs(args: string[]): ProfileCommandArgs { - const result: ProfileCommandArgs = {}; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - if (arg === '--base-url' && args[i + 1]) { - result.baseUrl = args[++i]; - } else if (arg === '--api-key' && args[i + 1]) { - result.apiKey = args[++i]; - } else if (arg === '--model' && args[i + 1]) { - result.model = args[++i]; - } else if (arg === '--force') { - result.force = true; - } else if (arg === '--yes' || arg === '-y') { - result.yes = true; - } else if (!arg.startsWith('-') && !result.name) { - result.name = arg; - } - } - - return result; -} - -/** - * Validate profile name - */ -function validateProfileName(name: string): string | null { - if (!name) { - return 'Profile name is required'; - } - if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) { - return 'Profile name must start with letter, contain only letters, numbers, dot, dash, underscore'; - } - if (name.length > 32) { - return 'Profile name must be 32 characters or less'; - } - // Reserved names - const reserved = ['default', 'auth', 'profile', 'doctor', 'sync', 'update', 'help', 'version']; - if (reserved.includes(name.toLowerCase())) { - return `'${name}' is a reserved name`; - } - return null; -} - -/** - * Validate URL format - */ -function validateUrl(url: string): string | null { - if (!url) { - return 'Base URL is required'; - } - try { - new URL(url); - return null; - } catch { - return 'Invalid URL format (must include protocol, e.g., https://)'; - } -} - -/** - * Check if profile already exists in config.json - */ -function profileExists(name: string): boolean { - try { - const config = loadConfig(); - return name in config.profiles; - } catch { - return false; - } -} - -/** - * Create settings.json file for profile - */ -function createSettingsFile(name: string, baseUrl: string, apiKey: string, model: string): string { - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${name}.settings.json`); - - const settings = { - env: { - ANTHROPIC_BASE_URL: baseUrl, - ANTHROPIC_AUTH_TOKEN: apiKey, - ANTHROPIC_MODEL: model, - }, - }; - - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); - return settingsPath; -} - -/** - * Update config.json with new profile - */ -function updateConfig(name: string, _settingsPath: string): void { - const configPath = getConfigPath(); - const ccsDir = getCcsDir(); - - // Read existing config or create new - let config: { profiles: Record; cliproxy?: Record }; - try { - config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - } catch { - config = { profiles: {} }; - } - - // Use relative path with ~ for portability - const relativePath = `~/.ccs/${name}.settings.json`; - config.profiles[name] = relativePath; - - // Ensure directory exists - if (!fs.existsSync(ccsDir)) { - fs.mkdirSync(ccsDir, { recursive: true }); - } - - // Write config atomically (write to temp, then rename) - const tempPath = configPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); - fs.renameSync(tempPath, configPath); -} - -/** - * Handle 'ccs profile create' command - */ -async function handleCreate(args: string[]): Promise { - const parsedArgs = parseArgs(args); - - console.log(colored('Create API Profile', 'bold')); - console.log(''); - - // Step 1: Profile name - let name = parsedArgs.name; - if (!name) { - name = await InteractivePrompt.input('Profile name', { - validate: validateProfileName, - }); - } else { - const error = validateProfileName(name); - if (error) { - console.error(`[X] ${error}`); - process.exit(1); - } - } - - // Check if exists - if (profileExists(name) && !parsedArgs.force) { - console.error(`[X] Profile '${name}' already exists`); - console.log(` Use ${colored('--force', 'yellow')} to overwrite`); - process.exit(1); - } - - // Step 2: Base URL - let baseUrl = parsedArgs.baseUrl; - if (!baseUrl) { - baseUrl = await InteractivePrompt.input('API Base URL (e.g., https://api.example.com)', { - validate: validateUrl, - }); - } else { - const error = validateUrl(baseUrl); - if (error) { - console.error(`[X] ${error}`); - process.exit(1); - } - } - - // Step 3: API Key - let apiKey = parsedArgs.apiKey; - if (!apiKey) { - apiKey = await InteractivePrompt.password('API Key'); - if (!apiKey) { - console.error('[X] API key is required'); - process.exit(1); - } - } - - // Step 4: Model (optional) - const defaultModel = 'claude-sonnet-4-5-20250929'; - let model = parsedArgs.model; - if (!model && !parsedArgs.yes) { - model = await InteractivePrompt.input('Default model', { - default: defaultModel, - }); - } - model = model || defaultModel; - - // Create files - console.log(''); - console.log('[i] Creating profile...'); - - try { - const settingsPath = createSettingsFile(name, baseUrl, apiKey, model); - updateConfig(name, settingsPath); - - console.log(colored('[OK] Profile created successfully', 'green')); - console.log(''); - console.log(` Profile: ${name}`); - console.log(` Settings: ~/.ccs/${name}.settings.json`); - console.log(` Base URL: ${baseUrl}`); - console.log(` Model: ${model}`); - console.log(''); - console.log(colored('Usage:', 'cyan')); - console.log(` ${colored(`ccs ${name} "your prompt"`, 'yellow')}`); - console.log(''); - } catch (error) { - console.error(`[X] Failed to create profile: ${(error as Error).message}`); - process.exit(1); - } -} - -/** - * Handle 'ccs profile list' command - */ -async function handleList(): Promise { - console.log(colored('CCS Profiles', 'bold')); - console.log(''); - - try { - const config = loadConfig(); - const profiles = Object.keys(config.profiles); - - if (profiles.length === 0) { - console.log(colored('No profiles configured', 'yellow')); - console.log(''); - console.log('To create a profile:'); - console.log(` ${colored('ccs profile create', 'yellow')}`); - console.log(''); - return; - } - - console.log(colored('Settings-based Profiles:', 'cyan')); - // Calculate max name length for alignment - const maxNameLen = Math.max(...profiles.map((n) => n.length)); - profiles.forEach((name) => { - const settingsPath = config.profiles[name]; - const paddedName = name.padEnd(maxNameLen); - console.log(` ${colored(paddedName, 'yellow')} ${settingsPath}`); - }); - console.log(''); - - // Also show CLIProxy profiles if any - if (config.cliproxy && Object.keys(config.cliproxy).length > 0) { - console.log(colored('CLIProxy Variants:', 'cyan')); - Object.entries(config.cliproxy).forEach(([name, variant]) => { - const v = variant as { provider: string; settings: string }; - console.log(` ${colored(name, 'yellow')} → ${v.provider} (${v.settings})`); - }); - console.log(''); - } - - console.log(`Total: ${profiles.length} profile(s)`); - console.log(''); - } catch (error) { - console.error(`[X] Failed to list profiles: ${(error as Error).message}`); - process.exit(1); - } -} - -/** - * Handle 'ccs profile remove' command - */ -async function handleRemove(args: string[]): Promise { - const parsedArgs = parseArgs(args); - - // Load config first to get available profiles - let config: { profiles: Record; cliproxy?: Record }; - try { - config = loadConfig(); - } catch { - console.error('[X] Failed to load config'); - process.exit(1); - } - - const profiles = Object.keys(config.profiles); - if (profiles.length === 0) { - console.log(colored('No profiles to remove', 'yellow')); - process.exit(0); - } - - // Interactive profile selection if not provided - let name = parsedArgs.name; - if (!name) { - console.log(colored('Remove Profile', 'bold')); - console.log(''); - console.log('Available profiles:'); - profiles.forEach((p, i) => console.log(` ${i + 1}. ${p}`)); - console.log(''); - - name = await InteractivePrompt.input('Profile name to remove', { - validate: (val) => { - if (!val) return 'Profile name is required'; - if (!profiles.includes(val)) return `Profile '${val}' not found`; - return null; - }, - }); - } - - if (!(name in config.profiles)) { - console.error(`[X] Profile '${name}' not found`); - console.log(''); - console.log('Available profiles:'); - profiles.forEach((p) => console.log(` - ${p}`)); - process.exit(1); - } - - const settingsPath = config.profiles[name]; - const expandedPath = path.join(getCcsDir(), `${name}.settings.json`); - - // Confirm deletion - console.log(''); - console.log(`Profile '${colored(name, 'yellow')}' will be removed.`); - console.log(` Settings: ${settingsPath}`); - console.log(''); - - const confirmed = - parsedArgs.yes || (await InteractivePrompt.confirm('Delete this profile?', { default: false })); - - if (!confirmed) { - console.log('[i] Cancelled'); - process.exit(0); - } - - // Remove from config.json - delete config.profiles[name]; - const configPath = getConfigPath(); - const tempPath = configPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); - fs.renameSync(tempPath, configPath); - - // Remove settings file if it exists - if (fs.existsSync(expandedPath)) { - fs.unlinkSync(expandedPath); - } - - console.log(colored('[OK] Profile removed', 'green')); - console.log(` Profile: ${name}`); - console.log(''); -} - -/** - * Show help for profile commands - */ -function showHelp(): void { - console.log(colored('CCS Profile Management', 'bold')); - console.log(''); - console.log(colored('Usage:', 'cyan')); - console.log(` ${colored('ccs profile', 'yellow')} [options]`); - console.log(''); - console.log(colored('Commands:', 'cyan')); - console.log(` ${colored('create [name]', 'yellow')} Create new API profile (interactive)`); - console.log(` ${colored('list', 'yellow')} List all profiles`); - console.log(` ${colored('remove ', 'yellow')} Remove a profile`); - console.log(''); - console.log(colored('Options:', 'cyan')); - console.log(` ${colored('--base-url ', 'yellow')} API base URL (create)`); - console.log(` ${colored('--api-key ', 'yellow')} API key (create)`); - console.log(` ${colored('--model ', 'yellow')} Default model (create)`); - console.log(` ${colored('--force', 'yellow')} Overwrite existing (create)`); - console.log(` ${colored('--yes, -y', 'yellow')} Skip confirmation prompts`); - console.log(''); - console.log(colored('Examples:', 'cyan')); - console.log(` ${colored('ccs profile create', 'yellow')} # Interactive wizard`); - console.log(` ${colored('ccs profile create myapi', 'yellow')} # Create with name`); - console.log(` ${colored('ccs profile remove myapi', 'yellow')} # Remove profile`); - console.log(` ${colored('ccs profile list', 'yellow')} # Show all profiles`); - console.log(''); -} - -/** - * Main profile command router - */ -export async function handleProfileCommand(args: string[]): Promise { - const command = args[0]; - - if (!command || command === '--help' || command === '-h' || command === 'help') { - showHelp(); - return; - } - - switch (command) { - case 'create': - await handleCreate(args.slice(1)); - break; - case 'list': - await handleList(); - break; - case 'remove': - case 'delete': - case 'rm': - await handleRemove(args.slice(1)); - break; - default: - console.error(`[X] Unknown command: ${command}`); - console.log(''); - console.log('Run for help:'); - console.log(` ${colored('ccs profile --help', 'yellow')}`); - process.exit(1); - } -} diff --git a/src/delegation/delegation-handler.ts b/src/delegation/delegation-handler.ts index c9ac176f..da245f6a 100644 --- a/src/delegation/delegation-handler.ts +++ b/src/delegation/delegation-handler.ts @@ -45,7 +45,7 @@ export class DelegationHandler { const result = await HeadlessExecutor.execute(parsed.profile, parsed.prompt, parsed.options); // 5. Format and display results - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); console.log(formatted); // 6. Exit with proper code @@ -83,7 +83,7 @@ export class DelegationHandler { sessionId: lastSession.sessionId, }); - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); console.log(formatted); process.exit(result.exitCode || 0); diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 0d76c578..a9d723d2 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -6,6 +6,7 @@ import * as os from 'os'; import * as fs from 'fs'; import { SessionManager } from './session-manager'; import { SettingsParser } from './settings-parser'; +import { ui } from '../utils/ui'; // Type definitions for delegation responses interface ClaudeMessage { @@ -214,6 +215,9 @@ export class HeadlessExecutor { console.error(`[i] Claude CLI args: ${args.join(' ')}`); } + // Initialize UI before spawning + await ui.init(); + // Execute with spawn return new Promise((resolve, reject) => { const startTime = Date.now(); @@ -225,7 +229,7 @@ export class HeadlessExecutor { if (showProgress) { const modelName = profile === 'glm' ? 'GLM-4.6' : profile === 'kimi' ? 'Kimi' : profile.toUpperCase(); - console.error(`[i] Delegating to ${modelName}...`); + console.error(ui.info(`Delegating to ${modelName}...`)); } const proc = spawn(claudeCli, args, { @@ -274,7 +278,7 @@ export class HeadlessExecutor { if (showProgress) { progressInterval = setInterval(() => { const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - process.stderr.write(`[i] Still running... ${elapsed}s elapsed\r`); + process.stderr.write(`${ui.info(`Still running... ${elapsed}s elapsed`)}\r`); }, 5000); } @@ -445,9 +449,9 @@ export class HeadlessExecutor { if (showProgress) { const durationSec = (duration / 1000).toFixed(1); if (timedOut) { - console.error(`[i] Execution timed out after ${durationSec}s`); + console.error(ui.warn(`Execution timed out after ${durationSec}s`)); } else { - console.error(`[i] Execution completed in ${durationSec}s`); + console.error(ui.info(`Execution completed in ${durationSec}s`)); } console.error(''); // Blank line before formatted output } diff --git a/src/delegation/result-formatter.ts b/src/delegation/result-formatter.ts index b0c9b6c1..1b2497d8 100644 --- a/src/delegation/result-formatter.ts +++ b/src/delegation/result-formatter.ts @@ -2,12 +2,13 @@ /** * Formats delegation execution results for display - * Creates ASCII box output with file change tracking + * Creates styled box output with file change tracking */ import * as path from 'path'; import { execSync } from 'child_process'; import * as fs from 'fs'; +import { ui } from '../utils/ui'; interface ExecutionResult { profile: string; @@ -55,19 +56,15 @@ class ResultFormatter { /** * Format execution result with complete source-of-truth */ - static format(result: ExecutionResult): string { + static async format(result: ExecutionResult): Promise { + await ui.init(); + const { profile, - cwd, - exitCode, stdout, stderr, - duration, success, content, - sessionId, - totalCost, - numTurns, subtype, permissionDenials, errors, @@ -90,15 +87,25 @@ class ResultFormatter { // Build formatted output let output = ''; - // Header - output += this.formatHeader(profile, success); + // Header box + const modelName = this.getModelDisplayName(profile); + const headerIcon = success ? '[i]' : '[X]'; + output += ui.box(`${headerIcon} Delegated to ${modelName} (ccs:${profile})`, { + borderStyle: 'round', + padding: 0, + }); + output += '\n\n'; - // Info box (file detection handled by delegated session itself) - output += this.formatInfoBox(cwd, profile, duration, exitCode, sessionId, totalCost, numTurns); + // Info table + output += this.formatInfoTable(result); + output += '\n'; // Task output - output += '\n'; - output += this.formatOutput(displayOutput); + if (displayOutput?.trim()) { + output += displayOutput.trim() + '\n'; + } else { + output += ui.info('No output from delegated task') + '\n'; + } // Permission denials if present if (permissionDenials && permissionDenials.length > 0) { @@ -115,12 +122,14 @@ class ResultFormatter { // Stderr if present if (stderr && stderr.trim()) { output += '\n'; - output += this.formatStderr(stderr); + output += ui.warn('Stderr:') + '\n'; + output += stderr.trim() + '\n'; } // Footer output += '\n'; - output += this.formatFooter(success, duration); + output += success ? ui.ok('Delegation completed') : ui.fail('Delegation failed'); + output += '\n'; return output; } @@ -242,107 +251,36 @@ class ResultFormatter { } /** - * Format header with delegation indicator + * Format info as table */ - private static formatHeader(profile: string, success: boolean): string { - const modelName = this.getModelDisplayName(profile); - const icon = success ? '[i]' : '[X]'; - return `${icon} Delegated to ${modelName} (ccs:${profile})\n`; - } - - /** - * Format info box with delegation details - */ - private static formatInfoBox( - cwd: string, - profile: string, - duration: number, - exitCode: number, - sessionId?: string, - totalCost?: number, - numTurns?: number - ): string { + private static formatInfoTable(result: ExecutionResult): string { + const { cwd, profile, duration, exitCode, sessionId, totalCost, numTurns } = result; const modelName = this.getModelDisplayName(profile); const durationSec = (duration / 1000).toFixed(1); - // Calculate box width (fit longest line + padding) - const maxWidth = 70; - const cwdLine = `Working Directory: ${cwd}`; - const boxWidth = Math.min(Math.max(cwdLine.length + 4, 50), maxWidth); - - const lines: string[] = [ - `Working Directory: ${this.truncate(cwd, boxWidth - 22)}`, - `Model: ${modelName}`, - `Duration: ${durationSec}s`, - `Exit Code: ${exitCode}`, + const rows: string[][] = [ + ['Working Dir', this.truncate(cwd, 40)], + ['Model', modelName], + ['Duration', `${durationSec}s`], + ['Exit Code', `${exitCode}`], ]; - // Add JSON-specific fields if available if (sessionId) { - // Abbreviate session ID (Git-style first 8 chars) to prevent wrapping const shortId = sessionId.length > 8 ? sessionId.substring(0, 8) : sessionId; - lines.push(`Session ID: ${shortId}`); + rows.push(['Session', shortId]); } + if (totalCost !== undefined && totalCost !== null) { - lines.push(`Cost: $${totalCost.toFixed(4)}`); + rows.push(['Cost', `$${totalCost.toFixed(4)}`]); } + if (numTurns) { - lines.push(`Turns: ${numTurns}`); + rows.push(['Turns', `${numTurns}`]); } - let box = ''; - box += '╔' + '═'.repeat(boxWidth - 2) + '╗\n'; - - for (const line of lines) { - const padding = boxWidth - line.length - 4; - box += '║ ' + line + ' '.repeat(Math.max(0, padding)) + ' ║\n'; - } - - box += '╚' + '═'.repeat(boxWidth - 2) + '╝'; - - return box; - } - - /** - * Format task output - */ - private static formatOutput(output: string): string { - if (!output || !output.trim()) { - return '[i] No output from delegated task\n'; - } - - return output.trim() + '\n'; - } - - /** - * Format stderr output - */ - private static formatStderr(stderr: string): string { - return `[!] Stderr:\n${stderr.trim()}\n\n`; - } - - /** - * Format file list (created or modified) - Currently unused - */ - /* - private static formatFileList(label: string, files: string[]): string { - let output = `[i] ${label} Files:\n`; - - for (const file of files) { - output += ` - ${file}\n`; - } - - return output; - } - */ - - /** - * Format footer with completion status - */ - private static formatFooter(success: boolean, _duration: number): string { - const icon = success ? '[OK]' : '[X]'; - const status = success ? 'Delegation completed' : 'Delegation failed'; - return `${icon} ${status}\n`; + return ui.table(rows, { + colWidths: [15, 45], + }); } /** @@ -372,10 +310,11 @@ class ResultFormatter { /** * Format minimal result (for quick tasks) */ - static formatMinimal(result: ExecutionResult): string { + static async formatMinimal(result: ExecutionResult): Promise { + await ui.init(); const { profile, success, duration } = result; const modelName = this.getModelDisplayName(profile); - const icon = success ? '[OK]' : '[X]'; + const icon = success ? ui.ok('') : ui.fail(''); const durationSec = (duration / 1000).toFixed(1); return `${icon} ${modelName} delegation ${success ? 'completed' : 'failed'} (${durationSec}s)\n`; @@ -384,8 +323,8 @@ class ResultFormatter { /** * Format verbose result (with full details) */ - static formatVerbose(result: ExecutionResult): string { - const basic = this.format(result); + static async formatVerbose(result: ExecutionResult): Promise { + const basic = await this.format(result); // Add additional debug info let verbose = basic; @@ -413,47 +352,50 @@ class ResultFormatter { /** * Format timeout error (session exceeded time limit) */ - private static formatTimeoutError(result: ExecutionResult): string { - const { profile, cwd, duration, sessionId, totalCost, numTurns, permissionDenials } = result; + private static async formatTimeoutError(result: ExecutionResult): Promise { + await ui.init(); + + const { profile, duration, sessionId, totalCost, permissionDenials } = result; + const modelName = this.getModelDisplayName(profile); + const timeoutMin = (duration / 60000).toFixed(1); let output = ''; - // Header - output += this.formatHeader(profile, false); - - // Info box - output += this.formatInfoBox(cwd, profile, duration, 0, sessionId, totalCost, numTurns); - - // Timeout message + // Error header + output += ui.errorBox( + `Execution Timeout\n\n` + + `Delegation to ${modelName} exceeded time limit.\n` + + `Session was gracefully terminated after ${timeoutMin} minutes.`, + 'TIMEOUT' + ); output += '\n'; - const timeoutMin = (duration / 60000).toFixed(1); - output += `[!] Execution timed out after ${timeoutMin} minutes\n\n`; - output += 'The delegated session exceeded its time limit before completing the task.\n'; - output += 'Session was gracefully terminated and saved for continuation.\n'; - // Permission denials if present + // Info table + output += this.formatInfoTable(result); + output += '\n'; + + // Permission denials if (permissionDenials && permissionDenials.length > 0) { - output += '\n'; + output += ui.warn('Permission denials may have caused delays:') + '\n'; output += this.formatPermissionDenials(permissionDenials); output += '\n'; - output += 'The task may require permissions that were denied.\n'; - output += 'Consider running with --permission-mode bypassPermissions or execute manually.\n'; } // Suggestions - output += '\n'; - output += 'Suggestions:\n'; - output += ` - Continue session: ccs ${profile}:continue -p "finish the task"\n`; - output += ` - Increase timeout: ccs ${profile} -p "task" --timeout ${duration * 2}\n`; - output += ' - Break task into smaller steps\n'; - output += ' - Run task manually in main Claude session\n'; + output += ui.header('SUGGESTIONS') + '\n'; + output += ` Continue session:\n`; + output += ` ${ui.color(`ccs ${profile}:continue "finish the task"`, 'command')}\n\n`; + output += ` Increase timeout:\n`; + output += ` ${ui.color(`ccs ${profile} --timeout ${Math.round((duration * 2) / 1000)}`, 'command')}\n\n`; + output += ` Break into smaller tasks\n\n`; - output += '\n'; - // Abbreviate session ID (Git-style first 8 chars) - const shortId = sessionId && sessionId.length > 8 ? sessionId.substring(0, 8) : sessionId; - output += `[i] Session persisted with ID: ${shortId}\n`; + // Session info + if (sessionId) { + const shortId = sessionId.length > 8 ? sessionId.substring(0, 8) : sessionId; + output += ui.dim(`Session persisted: ${shortId}`) + '\n'; + } if (totalCost !== undefined && totalCost !== null) { - output += `[i] Cost: $${totalCost.toFixed(4)}\n`; + output += ui.dim(`Cost: $${totalCost.toFixed(4)}`) + '\n'; } return output; @@ -463,14 +405,13 @@ class ResultFormatter { * Format permission denials */ private static formatPermissionDenials(denials: PermissionDenial[]): string { - let output = '[!] Permission Denials:\n'; + let output = ui.warn('Permission Denials:') + '\n'; for (const denial of denials) { const tool = denial.tool_name || 'Unknown'; const input = denial.tool_input || {}; - const command = input.command || input.description || JSON.stringify(input); - - output += ` - ${tool}: ${command}\n`; + const cmd = input.command || input.description || JSON.stringify(input); + output += ` - ${tool}: ${this.truncate(cmd, 50)}\n`; } return output; @@ -480,11 +421,11 @@ class ResultFormatter { * Format errors array */ private static formatErrors(errors: ErrorInfo[]): string { - let output = '[X] Errors:\n'; + let output = ui.fail('Errors:') + '\n'; for (const error of errors) { - const message = error.message || error.error || JSON.stringify(error); - output += ` - ${message}\n`; + const msg = error.message || error.error || JSON.stringify(error); + output += ` - ${msg}\n`; } return output; diff --git a/src/management/doctor.ts b/src/management/doctor.ts index 17bb0366..ae9a98d4 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -6,8 +6,8 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { spawn } from 'child_process'; -import { colored } from '../utils/helpers'; import { detectClaudeCli } from '../utils/claude-detector'; +import { initUI, header, box, table, color, ok, fail, warn, info } from '../utils/ui'; import packageJson from '../../package.json'; import { isCLIProxyInstalled, @@ -50,9 +50,6 @@ try { }; } -// Import cli-table3 -const Table = require('cli-table3'); - interface HealthCheckDetails { status: 'OK' | 'ERROR' | 'WARN'; info: string; @@ -133,40 +130,43 @@ class Doctor { * Run all health checks */ async runAllChecks(): Promise { - console.log(colored('Running CCS Health Check...', 'cyan')); + await initUI(); + + // Hero header box + console.log(box(`CCS Health Check v${this.ccsVersion}`, { borderStyle: 'round', padding: 0 })); console.log(''); // Store CCS version in details this.results.details['CCS Version'] = { status: 'OK', info: `v${this.ccsVersion}` }; // Group 1: System - console.log(colored('System:', 'bold')); + console.log(header('SYSTEM')); await this.checkClaudeCli(); this.checkCcsDirectory(); console.log(''); // Group 2: Configuration - console.log(colored('Configuration:', 'bold')); + console.log(header('CONFIGURATION')); this.checkConfigFiles(); this.checkClaudeSettings(); console.log(''); // Group 3: Profiles & Delegation - console.log(colored('Profiles & Delegation:', 'bold')); + console.log(header('PROFILES & DELEGATION')); this.checkProfiles(); this.checkInstances(); this.checkDelegation(); console.log(''); // Group 4: System Health - console.log(colored('System Health:', 'bold')); + console.log(header('SYSTEM HEALTH')); this.checkPermissions(); this.checkCcsSymlinks(); this.checkSettingsSymlinks(); console.log(''); // Group 5: CLIProxy (OAuth profiles) - console.log(colored('CLIProxy (OAuth Profiles):', 'bold')); + console.log(header('CLIPROXY (OAUTH PROFILES)')); await this.checkCLIProxy(); console.log(''); @@ -183,7 +183,8 @@ class Doctor { const claudeCli = detectClaudeCli(); if (!claudeCli) { - spinner.fail(` ${'Claude CLI'.padEnd(26)}${colored('[X]', 'red')} Not found in PATH`); + spinner.fail(); + console.log(` ${fail('Claude CLI'.padEnd(22))} Not found in PATH`); this.results.addCheck( 'Claude CLI', 'error', @@ -218,17 +219,15 @@ class Doctor { const versionMatch = result.match(/(\d+\.\d+\.\d+)/); const version = versionMatch ? versionMatch[1] : 'unknown'; - spinner.succeed( - ` ${'Claude CLI'.padEnd(26)}${colored('[OK]', 'green')} ${claudeCli} (v${version})` - ); + spinner.succeed(); + console.log(` ${ok('Claude CLI'.padEnd(22))} v${version} (${claudeCli})`); this.results.addCheck('Claude CLI', 'success', `Found: ${claudeCli}`, undefined, { status: 'OK', info: `v${version} (${claudeCli})`, }); } catch (_err) { - spinner.fail( - ` ${'Claude CLI'.padEnd(26)}${colored('[X]', 'red')} Not found or not working` - ); + spinner.fail(); + console.log(` ${fail('Claude CLI'.padEnd(22))} Not found or not working`); this.results.addCheck( 'Claude CLI', 'error', @@ -246,13 +245,15 @@ class Doctor { const spinner = ora('Checking ~/.ccs/ directory').start(); if (fs.existsSync(this.ccsDir)) { - spinner.succeed(` ${'CCS Directory'.padEnd(26)}${colored('[OK]', 'green')} ~/.ccs/`); + spinner.succeed(); + console.log(` ${ok('CCS Directory'.padEnd(22))} ~/.ccs/`); this.results.addCheck('CCS Directory', 'success', undefined, undefined, { status: 'OK', info: '~/.ccs/', }); } else { - spinner.fail(` ${'CCS Directory'.padEnd(26)}${colored('[X]', 'red')} Not found`); + spinner.fail(); + console.log(` ${fail('CCS Directory'.padEnd(22))} Not found`); this.results.addCheck( 'CCS Directory', 'error', @@ -289,7 +290,8 @@ class Doctor { const spinner = ora(`Checking ${file.name}`).start(); if (!fs.existsSync(file.path)) { - spinner.fail(` ${file.name.padEnd(26)}${colored('[X]', 'red')} Not found`); + spinner.fail(); + console.log(` ${fail(file.name.padEnd(22))} Not found`); this.results.addCheck( file.name, 'error', @@ -306,7 +308,7 @@ class Doctor { JSON.parse(content); // Extract useful info based on file type - let info = 'Valid'; + let fileInfo = 'Valid'; let status: 'OK' | 'WARN' = 'OK'; if (file.profile) { @@ -314,23 +316,23 @@ class Doctor { const validation = DelegationValidator.validate(file.profile); if (validation.valid) { - info = 'Key configured'; + fileInfo = 'Key configured'; status = 'OK'; } else if (validation.error && validation.error.includes('placeholder')) { - info = 'Placeholder key (not configured)'; + fileInfo = 'Placeholder key'; status = 'WARN'; } else { - info = 'Valid JSON'; + fileInfo = 'Valid JSON'; status = 'OK'; } } - const statusIcon = status === 'OK' ? colored('[OK]', 'green') : colored('[!]', 'yellow'); - if (status === 'WARN') { - spinner.warn(` ${file.name.padEnd(26)}${statusIcon} ${info}`); + spinner.warn(); + console.log(` ${warn(file.name.padEnd(22))} ${fileInfo}`); } else { - spinner.succeed(` ${file.name.padEnd(26)}${statusIcon} ${info}`); + spinner.succeed(); + console.log(` ${ok(file.name.padEnd(22))} ${fileInfo}`); } this.results.addCheck( @@ -340,11 +342,12 @@ class Doctor { undefined, { status: status, - info: info, + info: fileInfo, } ); } catch (e) { - spinner.fail(` ${file.name.padEnd(26)}${colored('[X]', 'red')} Invalid JSON`); + spinner.fail(); + console.log(` ${fail(file.name.padEnd(22))} Invalid JSON`); this.results.addCheck( file.name, 'error', @@ -362,11 +365,11 @@ class Doctor { private checkClaudeSettings(): void { const spinner = ora('Checking ~/.claude/settings.json').start(); const settingsPath = path.join(this.claudeDir, 'settings.json'); + const settingsName = '~/.claude/settings.json'; if (!fs.existsSync(settingsPath)) { - spinner.warn( - ` ${'~/.claude/settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Not found` - ); + spinner.warn(); + console.log(` ${warn(settingsName.padEnd(22))} Not found`); this.results.addCheck( 'Claude Settings', 'warning', @@ -380,12 +383,12 @@ class Doctor { try { const content = fs.readFileSync(settingsPath, 'utf8'); JSON.parse(content); - spinner.succeed(` ${'~/.claude/settings.json'.padEnd(26)}${colored('[OK]', 'green')}`); + spinner.succeed(); + console.log(` ${ok(settingsName.padEnd(22))} Valid`); this.results.addCheck('Claude Settings', 'success'); } catch (e) { - spinner.warn( - ` ${'~/.claude/settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Invalid JSON` - ); + spinner.warn(); + console.log(` ${warn(settingsName.padEnd(22))} Invalid JSON`); this.results.addCheck( 'Claude Settings', 'warning', @@ -403,7 +406,8 @@ class Doctor { const configPath = path.join(this.ccsDir, 'config.json'); if (!fs.existsSync(configPath)) { - spinner.info(` ${'Profiles'.padEnd(26)}${colored('[SKIP]', 'cyan')} config.json not found`); + spinner.info(); + console.log(` ${info('Profiles'.padEnd(22))} config.json not found`); return; } @@ -411,7 +415,8 @@ class Doctor { const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); if (!config.profiles || typeof config.profiles !== 'object') { - spinner.fail(` ${'Profiles'.padEnd(26)}${colored('[X]', 'red')} Missing profiles object`); + spinner.fail(); + console.log(` ${fail('Profiles'.padEnd(22))} Missing profiles object`); this.results.addCheck( 'Profiles', 'error', @@ -425,9 +430,8 @@ class Doctor { const profileCount = Object.keys(config.profiles).length; const profileNames = Object.keys(config.profiles).join(', '); - spinner.succeed( - ` ${'Profiles'.padEnd(26)}${colored('[OK]', 'green')} ${profileCount} configured (${profileNames})` - ); + spinner.succeed(); + console.log(` ${ok('Profiles'.padEnd(22))} ${profileCount} configured (${profileNames})`); this.results.addCheck( 'Profiles', 'success', @@ -439,7 +443,8 @@ class Doctor { } ); } catch (e) { - spinner.fail(` ${'Profiles'.padEnd(26)}${colored('[X]', 'red')} ${(e as Error).message}`); + spinner.fail(); + console.log(` ${fail('Profiles'.padEnd(22))} ${(e as Error).message}`); this.results.addCheck('Profiles', 'error', (e as Error).message, undefined, { status: 'ERROR', info: (e as Error).message, @@ -455,7 +460,8 @@ class Doctor { const instancesDir = path.join(this.ccsDir, 'instances'); if (!fs.existsSync(instancesDir)) { - spinner.info(` ${'Instances'.padEnd(26)}${colored('[i]', 'cyan')} No account profiles`); + spinner.info(); + console.log(` ${info('Instances'.padEnd(22))} No account profiles`); this.results.addCheck('Instances', 'success', 'No account profiles configured'); return; } @@ -465,14 +471,14 @@ class Doctor { }); if (instances.length === 0) { - spinner.info(` ${'Instances'.padEnd(26)}${colored('[i]', 'cyan')} No account profiles`); + spinner.info(); + console.log(` ${info('Instances'.padEnd(22))} No account profiles`); this.results.addCheck('Instances', 'success', 'No account profiles'); return; } - spinner.succeed( - ` ${'Instances'.padEnd(26)}${colored('[OK]', 'green')} ${instances.length} account profiles` - ); + spinner.succeed(); + console.log(` ${ok('Instances'.padEnd(22))} ${instances.length} account profiles`); this.results.addCheck('Instances', 'success', `${instances.length} account profiles`); } @@ -488,7 +494,8 @@ class Doctor { const hasContinueCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs', 'continue.md')); if (!hasCcsCommand || !hasContinueCommand) { - spinner.warn(` ${'Delegation'.padEnd(26)}${colored('[!]', 'yellow')} Not installed`); + spinner.warn(); + console.log(` ${warn('Delegation'.padEnd(22))} Not installed`); this.results.addCheck( 'Delegation', 'warning', @@ -511,7 +518,8 @@ class Doctor { } if (readyProfiles.length === 0) { - spinner.warn(` ${'Delegation'.padEnd(26)}${colored('[!]', 'yellow')} No profiles ready`); + spinner.warn(); + console.log(` ${warn('Delegation'.padEnd(22))} No profiles ready`); this.results.addCheck( 'Delegation', 'warning', @@ -522,8 +530,9 @@ class Doctor { return; } - spinner.succeed( - ` ${'Delegation'.padEnd(26)}${colored('[OK]', 'green')} ${readyProfiles.length} profiles ready (${readyProfiles.join(', ')})` + spinner.succeed(); + console.log( + ` ${ok('Delegation'.padEnd(22))} ${readyProfiles.length} profiles ready (${readyProfiles.join(', ')})` ); this.results.addCheck( 'Delegation', @@ -544,17 +553,15 @@ class Doctor { try { fs.writeFileSync(testFile, 'test', 'utf8'); fs.unlinkSync(testFile); - spinner.succeed( - ` ${'Permissions'.padEnd(26)}${colored('[OK]', 'green')} Write access verified` - ); + spinner.succeed(); + console.log(` ${ok('Permissions'.padEnd(22))} Write access verified`); this.results.addCheck('Permissions', 'success', undefined, undefined, { status: 'OK', info: 'Write access verified', }); } catch (_e) { - spinner.fail( - ` ${'Permissions'.padEnd(26)}${colored('[X]', 'red')} Cannot write to ~/.ccs/` - ); + spinner.fail(); + console.log(` ${fail('Permissions'.padEnd(22))} Cannot write to ~/.ccs/`); this.results.addCheck( 'Permissions', 'error', @@ -578,9 +585,8 @@ class Doctor { if (health.healthy) { const itemCount = manager.ccsItems.length; - spinner.succeed( - ` ${'CCS Symlinks'.padEnd(26)}${colored('[OK]', 'green')} ${itemCount}/${itemCount} items linked` - ); + spinner.succeed(); + console.log(` ${ok('CCS Symlinks'.padEnd(22))} ${itemCount}/${itemCount} items linked`); this.results.addCheck( 'CCS Symlinks', 'success', @@ -592,9 +598,8 @@ class Doctor { } ); } else { - spinner.warn( - ` ${'CCS Symlinks'.padEnd(26)}${colored('[!]', 'yellow')} ${health.issues.length} issues found` - ); + spinner.warn(); + console.log(` ${warn('CCS Symlinks'.padEnd(22))} ${health.issues.length} issues found`); this.results.addCheck( 'CCS Symlinks', 'warning', @@ -604,7 +609,8 @@ class Doctor { ); } } catch (e) { - spinner.warn(` ${'CCS Symlinks'.padEnd(26)}${colored('[!]', 'yellow')} Could not check`); + spinner.warn(); + console.log(` ${warn('CCS Symlinks'.padEnd(22))} Could not check`); this.results.addCheck( 'CCS Symlinks', 'warning', @@ -620,6 +626,7 @@ class Doctor { */ private checkSettingsSymlinks(): void { const spinner = ora('Checking settings.json symlinks').start(); + const settingsLabel = 'settings.json'; try { const sharedDir = path.join(this.homedir, '.ccs', 'shared'); @@ -628,9 +635,8 @@ class Doctor { // Check shared settings exists and points to ~/.claude/ if (!fs.existsSync(sharedSettings)) { - spinner.warn( - ` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Not found` - ); + spinner.warn(); + console.log(` ${warn(settingsLabel.padEnd(22))} Not found (shared)`); this.results.addCheck( 'Settings Symlinks', 'warning', @@ -642,9 +648,8 @@ class Doctor { const sharedStats = fs.lstatSync(sharedSettings); if (!sharedStats.isSymbolicLink()) { - spinner.warn( - ` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Not a symlink` - ); + spinner.warn(); + console.log(` ${warn(settingsLabel.padEnd(22))} Not a symlink (shared)`); this.results.addCheck( 'Settings Symlinks', 'warning', @@ -658,9 +663,8 @@ class Doctor { const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget); if (resolvedShared !== claudeSettings) { - spinner.warn( - ` ${'settings.json (shared)'.padEnd(26)}${colored('[!]', 'yellow')} Wrong target` - ); + spinner.warn(); + console.log(` ${warn(settingsLabel.padEnd(22))} Wrong target (shared)`); this.results.addCheck( 'Settings Symlinks', 'warning', @@ -673,9 +677,8 @@ class Doctor { // Check each instance const instancesDir = path.join(this.ccsDir, 'instances'); if (!fs.existsSync(instancesDir)) { - spinner.succeed( - ` ${'settings.json'.padEnd(26)}${colored('[OK]', 'green')} Shared symlink valid` - ); + spinner.succeed(); + console.log(` ${ok(settingsLabel.padEnd(22))} Shared symlink valid`); this.results.addCheck('Settings Symlinks', 'success', 'Shared symlink valid', undefined, { status: 'OK', info: 'Shared symlink valid', @@ -716,9 +719,8 @@ class Doctor { } if (broken > 0) { - spinner.warn( - ` ${'settings.json'.padEnd(26)}${colored('[!]', 'yellow')} ${broken} broken instance(s)` - ); + spinner.warn(); + console.log(` ${warn(settingsLabel.padEnd(22))} ${broken} broken instance(s)`); this.results.addCheck( 'Settings Symlinks', 'warning', @@ -727,9 +729,8 @@ class Doctor { { status: 'WARN', info: `${broken} broken instance(s)` } ); } else { - spinner.succeed( - ` ${'settings.json'.padEnd(26)}${colored('[OK]', 'green')} ${instances.length} instance(s) valid` - ); + spinner.succeed(); + console.log(` ${ok(settingsLabel.padEnd(22))} ${instances.length} instance(s) valid`); this.results.addCheck( 'Settings Symlinks', 'success', @@ -742,7 +743,8 @@ class Doctor { ); } } catch (err) { - spinner.warn(` ${'settings.json'.padEnd(26)}${colored('[!]', 'yellow')} Check failed`); + spinner.warn(); + console.log(` ${warn(settingsLabel.padEnd(22))} Check failed`); this.results.addCheck( 'Settings Symlinks', 'warning', @@ -762,16 +764,16 @@ class Doctor { if (isCLIProxyInstalled()) { const binaryPath = getCLIProxyPath(); - binarySpinner.succeed( - ` ${'CLIProxy Binary'.padEnd(26)}${colored('[OK]', 'green')} v${CLIPROXY_VERSION}` - ); + binarySpinner.succeed(); + console.log(` ${ok('CLIProxy Binary'.padEnd(22))} v${CLIPROXY_VERSION}`); this.results.addCheck('CLIProxy Binary', 'success', undefined, undefined, { status: 'OK', info: `v${CLIPROXY_VERSION} (${binaryPath})`, }); } else { - binarySpinner.info( - ` ${'CLIProxy Binary'.padEnd(26)}${colored('[i]', 'cyan')} Not installed (downloads on first use)` + binarySpinner.info(); + console.log( + ` ${info('CLIProxy Binary'.padEnd(22))} Not installed (downloads on first use)` ); this.results.addCheck( 'CLIProxy Binary', @@ -787,17 +789,15 @@ class Doctor { const configPath = getConfigPath(); if (fs.existsSync(configPath)) { - configSpinner.succeed( - ` ${'CLIProxy Config'.padEnd(26)}${colored('[OK]', 'green')} cliproxy/config.yaml` - ); + configSpinner.succeed(); + console.log(` ${ok('CLIProxy Config'.padEnd(22))} cliproxy/config.yaml`); this.results.addCheck('CLIProxy Config', 'success', undefined, undefined, { status: 'OK', info: 'cliproxy/config.yaml', }); } else { - configSpinner.info( - ` ${'CLIProxy Config'.padEnd(26)}${colored('[i]', 'cyan')} Not created (generated on first use)` - ); + configSpinner.info(); + console.log(` ${info('CLIProxy Config'.padEnd(22))} Not created (on first use)`); this.results.addCheck('CLIProxy Config', 'success', 'Not created yet', undefined, { status: 'OK', info: 'Generated on first use', @@ -812,17 +812,15 @@ class Doctor { if (status.authenticated) { const lastAuth = status.lastAuth ? ` (${status.lastAuth.toLocaleDateString()})` : ''; - authSpinner.succeed( - ` ${`${providerName} Auth`.padEnd(26)}${colored('[OK]', 'green')} Authenticated${lastAuth}` - ); + authSpinner.succeed(); + console.log(` ${ok(`${providerName} Auth`.padEnd(22))} Authenticated${lastAuth}`); this.results.addCheck(`${providerName} Auth`, 'success', undefined, undefined, { status: 'OK', info: `Authenticated${lastAuth}`, }); } else { - authSpinner.info( - ` ${`${providerName} Auth`.padEnd(26)}${colored('[i]', 'cyan')} Not authenticated` - ); + authSpinner.info(); + console.log(` ${info(`${providerName} Auth`.padEnd(22))} Not authenticated`); this.results.addCheck( `${providerName} Auth`, 'success', @@ -838,17 +836,15 @@ class Doctor { const portAvailable = await isPortAvailable(CLIPROXY_DEFAULT_PORT); if (portAvailable) { - portSpinner.succeed( - ` ${'CLIProxy Port'.padEnd(26)}${colored('[OK]', 'green')} ${CLIPROXY_DEFAULT_PORT} available` - ); + portSpinner.succeed(); + console.log(` ${ok('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} available`); this.results.addCheck('CLIProxy Port', 'success', undefined, undefined, { status: 'OK', info: `Port ${CLIPROXY_DEFAULT_PORT} available`, }); } else { - portSpinner.warn( - ` ${'CLIProxy Port'.padEnd(26)}${colored('[!]', 'yellow')} ${CLIPROXY_DEFAULT_PORT} in use` - ); + portSpinner.warn(); + console.log(` ${warn('CLIProxy Port'.padEnd(22))} ${CLIPROXY_DEFAULT_PORT} in use`); this.results.addCheck( 'CLIProxy Port', 'warning', @@ -864,68 +860,48 @@ class Doctor { */ private showReport(): void { console.log(''); - - // Calculate exact table width to match header bars - // colWidths: [20, 10, 35] = 65 + borders (4) = 69 total - const tableWidth = 69; - const headerBar = '═'.repeat(tableWidth); - - console.log(colored(headerBar, 'cyan')); - console.log(colored(' Health Check Summary', 'bold')); - console.log(colored(headerBar, 'cyan')); - - // Create summary table with detailed information - const table = new Table({ - head: [colored('Component', 'cyan'), colored('Status', 'cyan'), colored('Details', 'cyan')], - colWidths: [20, 10, 35], - wordWrap: true, - chars: { - top: '═', - 'top-mid': '╤', - 'top-left': '╔', - 'top-right': '╗', - bottom: '═', - 'bottom-mid': '╧', - 'bottom-left': '╚', - 'bottom-right': '╝', - left: '║', - 'left-mid': '╟', - mid: '─', - 'mid-mid': '┼', - right: '║', - 'right-mid': '╢', - middle: '│', - }, - }); - - // Populate table with collected details - for (const [component, detail] of Object.entries(this.results.details)) { - const statusColor = - detail.status === 'OK' ? 'green' : detail.status === 'ERROR' ? 'red' : 'yellow'; - table.push([component, colored(detail.status, statusColor), detail.info || '']); - } - - console.log(table.toString()); + console.log(header('HEALTH CHECK SUMMARY')); console.log(''); - // Show errors and warnings if present + // Build summary table rows + const rows: string[][] = Object.entries(this.results.details).map(([component, detail]) => { + const statusIndicator = + detail.status === 'OK' + ? color('[OK]', 'success') + : detail.status === 'ERROR' + ? color('[X]', 'error') + : color('[!]', 'warning'); + + return [component, statusIndicator, detail.info || '']; + }); + + console.log( + table(rows, { + head: ['Component', 'Status', 'Details'], + colWidths: [20, 12, 35], + }) + ); + console.log(''); + + // Show errors if present if (this.results.hasErrors()) { - console.log(colored('Errors:', 'red')); + console.log(header('ERRORS')); this.results.errors.forEach((err) => { - console.log(` [X] ${err.name}: ${err.message}`); + console.log(` ${fail(err.name)}: ${err.message}`); if (err.fix) { - console.log(` Fix: ${err.fix}`); + console.log(` Fix: ${color(err.fix, 'command')}`); } }); console.log(''); } + // Show warnings if present if (this.results.hasWarnings()) { - console.log(colored('Warnings:', 'yellow')); - this.results.warnings.forEach((warn) => { - console.log(` [!] ${warn.name}: ${warn.message}`); - if (warn.fix) { - console.log(` Fix: ${warn.fix}`); + console.log(header('WARNINGS')); + this.results.warnings.forEach((w) => { + console.log(` ${warn(w.name)}: ${w.message}`); + if (w.fix) { + console.log(` Fix: ${color(w.fix, 'command')}`); } }); console.log(''); @@ -933,12 +909,15 @@ class Doctor { // Final status if (this.results.isHealthy() && !this.results.hasWarnings()) { - console.log(colored('[OK] All checks passed! Your CCS installation is healthy.', 'green')); + console.log(ok('All checks passed! Installation is healthy.')); } else if (this.results.hasErrors()) { - console.log(colored('[X] Status: Installation has errors', 'red')); - console.log('Run suggested fixes above to resolve issues.'); + console.log(fail('Installation has errors. Run suggested fixes above.')); } else { - console.log(colored('[OK] Status: Installation healthy (warnings only)', 'green')); + console.log( + ok( + `Installation healthy (${this.results.warnings.length} warning${this.results.warnings.length !== 1 ? 's' : ''})` + ) + ); } console.log(''); diff --git a/src/types/utils.ts b/src/types/utils.ts index 8aa0f09f..d24651ac 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -41,3 +41,64 @@ export interface TerminalInfo { * Helper result types */ export type Result = { ok: true; value: T } | { ok: false; error: E }; + +// ============================================================================= +// UI TYPES (Phase 1: CLI UI/UX Enhancement) +// ============================================================================= + +/** + * Semantic color names for UI elements + */ +export type SemanticColor = + | 'success' // green - [OK] messages + | 'error' // red - [X] messages + | 'warning' // yellow - [!] messages + | 'info' // cyan - [i] messages + | 'dim' // gray - secondary text + | 'primary' // #00ECFA - headers, emphasis + | 'secondary' // #0099FF - links, accents + | 'command' // yellow italic - command examples + | 'path'; // cyan underline - file paths + +/** + * Box style options for boxen wrapper + */ +export interface BoxOptions { + title?: string; + titleAlignment?: 'left' | 'center' | 'right'; + padding?: number; + margin?: number; + borderColor?: string; + borderStyle?: 'single' | 'double' | 'round' | 'bold' | 'classic'; +} + +/** + * Table style options for cli-table3 wrapper + */ +export interface TableOptions { + head?: string[]; + colWidths?: number[]; + wordWrap?: boolean; + style?: 'unicode' | 'ascii'; +} + +/** + * Spinner options for ora wrapper + */ +export interface SpinnerOptions { + text: string; + color?: string; + prefixText?: string; +} + +/** + * Spinner control interface + */ +export interface SpinnerController { + succeed: (msg?: string) => void; + fail: (msg?: string) => void; + warn: (msg?: string) => void; + info: (msg?: string) => void; + update: (text: string) => void; + stop: () => void; +} diff --git a/src/utils/error-manager.ts b/src/utils/error-manager.ts index d4a7fbe2..33380dd7 100644 --- a/src/utils/error-manager.ts +++ b/src/utils/error-manager.ts @@ -1,4 +1,4 @@ -import { colored } from './helpers'; +import { initUI, header, color, dim, errorBox } from './ui'; import { ERROR_CODES, getErrorDocUrl, ErrorCode } from './error-codes'; /** @@ -23,60 +23,70 @@ export class ErrorManager { * Show error code and documentation URL */ static showErrorCode(errorCode: ErrorCode): void { - console.error(colored(`Error: ${errorCode}`, 'yellow')); - console.error(colored(getErrorDocUrl(errorCode), 'yellow')); + console.error(dim(`Error: ${errorCode}`)); + console.error(dim(getErrorDocUrl(errorCode))); console.error(''); } /** * Show Claude CLI not found error */ - static showClaudeNotFound(): void { + static async showClaudeNotFound(): Promise { + await initUI(); + console.error(''); - console.error(colored('[X] Claude CLI not found', 'red')); + console.error( + errorBox( + 'Claude CLI not found\n\n' + + 'CCS requires Claude CLI to be installed\n' + + 'and available in PATH.', + 'ERROR' + ) + ); console.error(''); - console.error('CCS requires Claude CLI to be installed and available in PATH.'); + + console.error(header('SOLUTIONS')); console.error(''); - console.error(colored('Solutions:', 'yellow')); - console.error(' 1. Install Claude CLI:'); - console.error(' https://docs.claude.com/en/docs/claude-code/installation'); + console.error(' 1. Install Claude CLI'); + console.error(` ${color('https://docs.claude.com/install', 'path')}`); console.error(''); - console.error(' 2. Verify installation:'); - console.error(' command -v claude (Unix)'); - console.error(' Get-Command claude (Windows)'); + console.error(' 2. Verify installation'); + console.error(` ${color('command -v claude', 'command')} (Unix)`); + console.error(` ${color('Get-Command claude', 'command')} (Windows)`); console.error(''); - console.error(' 3. Custom path (if installed elsewhere):'); - console.error(' export CCS_CLAUDE_PATH="/path/to/claude"'); + console.error(' 3. Custom path (if installed elsewhere)'); + console.error(` ${color('export CCS_CLAUDE_PATH="/path/to/claude"', 'command')}`); console.error(''); + this.showErrorCode(ERROR_CODES.CLAUDE_NOT_FOUND); } /** * Show settings file not found error */ - static showSettingsNotFound(settingsPath: string): void { + static async showSettingsNotFound(settingsPath: string): Promise { + await initUI(); + const isClaudeSettings = settingsPath.includes('.claude') && settingsPath.endsWith('settings.json'); console.error(''); - console.error(colored('[X] Settings file not found', 'red')); - console.error(''); - console.error(`File: ${settingsPath}`); + console.error(errorBox('Settings file not found\n\n' + `File: ${settingsPath}`, 'ERROR')); console.error(''); if (isClaudeSettings) { console.error('This file is auto-created when you login to Claude CLI.'); console.error(''); - console.error(colored('Solutions:', 'yellow')); - console.error(` echo '{}' > ${settingsPath}`); - console.error(' claude /login'); + console.error(header('SOLUTIONS')); + console.error(` ${color(`echo '{}' > ${settingsPath}`, 'command')}`); + console.error(` ${color('claude /login', 'command')}`); console.error(''); - console.error('Why: Newer Claude CLI versions require explicit login.'); + console.error(dim('Why: Newer Claude CLI versions require explicit login.')); } else { - console.error(colored('Solutions:', 'yellow')); - console.error(' npm install -g @kaitranntt/ccs --force'); + console.error(header('SOLUTIONS')); + console.error(` ${color('npm install -g @kaitranntt/ccs --force', 'command')}`); console.error(''); - console.error('This will recreate missing profile settings.'); + console.error(dim('This will recreate missing profile settings.')); } console.error(''); @@ -86,120 +96,139 @@ export class ErrorManager { /** * Show invalid configuration error */ - static showInvalidConfig(configPath: string, errorDetail: string): void { + static async showInvalidConfig(configPath: string, errorDetail: string): Promise { + await initUI(); + console.error(''); - console.error(colored('[X] Configuration invalid', 'red')); + console.error( + errorBox( + 'Configuration invalid\n\n' + `File: ${configPath}\n` + `Issue: ${errorDetail}`, + 'ERROR' + ) + ); console.error(''); - console.error(`File: ${configPath}`); - console.error(`Issue: ${errorDetail}`); + + console.error(header('SOLUTIONS')); console.error(''); - console.error(colored('Solutions:', 'yellow')); - console.error(' # Backup corrupted file'); - console.error(` mv ${configPath} ${configPath}.backup`); + console.error(` ${dim('# Backup corrupted file')}`); + console.error(` ${color(`mv ${configPath} ${configPath}.backup`, 'command')}`); console.error(''); - console.error(' # Reinstall CCS'); - console.error(' npm install -g @kaitranntt/ccs --force'); + console.error(` ${dim('# Reinstall CCS')}`); + console.error(` ${color('npm install -g @kaitranntt/ccs --force', 'command')}`); console.error(''); - console.error('Your profile settings will be preserved.'); + console.error(dim('Your profile settings will be preserved.')); console.error(''); + this.showErrorCode(ERROR_CODES.CONFIG_INVALID_JSON); } /** * Show profile not found error */ - static showProfileNotFound( + static async showProfileNotFound( profileName: string, availableProfiles: string[], suggestions: string[] = [] - ): void { + ): Promise { + await initUI(); + console.error(''); - console.error(colored(`[X] Profile '${profileName}' not found`, 'red')); + console.error(errorBox(`Profile '${profileName}' not found`, 'ERROR')); console.error(''); if (suggestions && suggestions.length > 0) { - console.error(colored('Did you mean:', 'yellow')); - suggestions.forEach((s) => console.error(` ${s}`)); + console.error(header('DID YOU MEAN')); + suggestions.forEach((s) => console.error(` ${color(s, 'command')}`)); console.error(''); } - console.error(colored('Available profiles:', 'cyan')); - availableProfiles.forEach((line) => console.error(` ${line}`)); + console.error(header('AVAILABLE PROFILES')); + availableProfiles.forEach((line) => console.error(` ${color(line, 'info')}`)); console.error(''); - console.error(colored('Solutions:', 'yellow')); - console.error(' # Use existing profile'); - console.error(' ccs "your prompt"'); + + console.error(header('SOLUTIONS')); console.error(''); - console.error(' # Create new account profile'); - console.error(' ccs auth create '); + console.error(' Use an existing profile:'); + console.error(` ${color('ccs "your prompt"', 'command')}`); console.error(''); + console.error(' Create a new account profile:'); + console.error(` ${color('ccs auth create ', 'command')}`); + console.error(''); + this.showErrorCode(ERROR_CODES.PROFILE_NOT_FOUND); } /** * Show permission denied error */ - static showPermissionDenied(path: string): void { + static async showPermissionDenied(filePath: string): Promise { + await initUI(); + console.error(''); - console.error(colored('[X] Permission denied', 'red')); + console.error(errorBox('Permission denied\n\n' + `Cannot write to: ${filePath}`, 'ERROR')); console.error(''); - console.error(`Cannot write to: ${path}`); + + console.error(header('SOLUTIONS')); console.error(''); - console.error(colored('Solutions:', 'yellow')); - console.error(' # Fix ownership'); - console.error(' sudo chown -R $USER ~/.ccs ~/.claude'); + console.error(` ${dim('# Fix ownership')}`); + console.error(` ${color('sudo chown -R $USER ~/.ccs ~/.claude', 'command')}`); console.error(''); - console.error(' # Fix permissions'); - console.error(' chmod 755 ~/.ccs ~/.claude'); + console.error(` ${dim('# Fix permissions')}`); + console.error(` ${color('chmod 755 ~/.ccs ~/.claude', 'command')}`); console.error(''); - console.error(' # Retry installation'); - console.error(' npm install -g @kaitranntt/ccs --force'); + console.error(` ${dim('# Retry installation')}`); + console.error(` ${color('npm install -g @kaitranntt/ccs --force', 'command')}`); console.error(''); + this.showErrorCode(ERROR_CODES.FS_CANNOT_WRITE_FILE); } /** * Show CLIProxy OAuth timeout error */ - static showOAuthTimeout(provider: string): void { + static async showOAuthTimeout(provider: string): Promise { + await initUI(); + console.error(''); - console.error(colored('+---------------------------------------------------------+', 'red')); - console.error(colored('| OAuth Timeout |', 'red')); - console.error(colored('+---------------------------------------------------------+', 'red')); + console.error( + errorBox('OAuth Timeout\n\n' + 'Authentication did not complete within 2 minutes.', 'ERROR') + ); console.error(''); - console.error('Authentication did not complete within 2 minutes.'); - console.error(''); - console.error(colored('Troubleshooting:', 'yellow')); + + console.error(header('TROUBLESHOOTING')); console.error(' 1. Check if browser opened (popup blocker?)'); console.error(' 2. Complete login in browser, then return here'); console.error(' 3. Try different browser'); console.error(' 4. Disable browser extensions temporarily'); console.error(''); - console.error(colored('For headless/SSH environments:', 'cyan')); - console.error(` ccs ${provider} --auth --headless`); + + console.error(header('FOR HEADLESS/SSH ENVIRONMENTS')); + console.error(` ${color(`ccs ${provider} --auth --headless`, 'command')}`); console.error(''); - console.error('This displays manual authentication steps.'); + console.error(dim('This displays manual authentication steps.')); console.error(''); } /** * Show CLIProxy port conflict error */ - static showPortConflict(port: number): void { + static async showPortConflict(port: number): Promise { + await initUI(); + console.error(''); - console.error(colored('+---------------------------------------------------------+', 'red')); - console.error(colored('| Port Conflict |', 'red')); - console.error(colored('+---------------------------------------------------------+', 'red')); + console.error( + errorBox('Port Conflict\n\n' + `CLIProxy port ${port} is already in use.`, 'ERROR') + ); console.error(''); - console.error(`CLIProxy port ${port} is already in use.`); + + console.error(header('SOLUTIONS')); console.error(''); - console.error(colored('Solutions:', 'yellow')); console.error(' 1. Find process using port:'); - console.error(` lsof -i :${port} (macOS/Linux)`); - console.error(` netstat -ano | findstr ${port} (Windows)`); + console.error(` ${color(`lsof -i :${port}`, 'command')} (macOS/Linux)`); + console.error(` ${color(`netstat -ano | findstr ${port}`, 'command')} (Windows)`); console.error(''); console.error(' 2. Kill the process:'); - console.error(` lsof -ti:${port} | xargs kill -9`); + console.error(` ${color(`lsof -ti:${port} | xargs kill -9`, 'command')}`); console.error(''); console.error(' 3. Wait and retry (process may exit on its own)'); console.error(''); @@ -208,38 +237,43 @@ export class ErrorManager { /** * Show CLIProxy binary download failure error */ - static showBinaryDownloadFailed(url: string, error: string): void { + static async showBinaryDownloadFailed(url: string, error: string): Promise { + await initUI(); + console.error(''); - console.error(colored('+---------------------------------------------------------+', 'red')); - console.error(colored('| Binary Download Failed |', 'red')); - console.error(colored('+---------------------------------------------------------+', 'red')); + console.error(errorBox('Binary Download Failed\n\n' + `Error: ${error}`, 'ERROR')); console.error(''); - console.error(`Error: ${error}`); - console.error(''); - console.error(colored('Troubleshooting:', 'yellow')); + + console.error(header('TROUBLESHOOTING')); console.error(' 1. Check internet connection'); console.error(' 2. Check firewall/proxy settings'); console.error(' 3. Try again in a few minutes'); console.error(''); - console.error(colored('Manual download:', 'cyan')); - console.error(` URL: ${url}`); - console.error(' Save to: ~/.ccs/bin/cliproxyapi'); - console.error(' chmod +x ~/.ccs/bin/cliproxyapi'); + + console.error(header('MANUAL DOWNLOAD')); + console.error(` URL: ${color(url, 'path')}`); + console.error(` Save to: ${color('~/.ccs/bin/cliproxyapi', 'path')}`); + console.error(` ${color('chmod +x ~/.ccs/bin/cliproxyapi', 'command')}`); console.error(''); } /** * Show CLIProxy authentication required error */ - static showAuthRequired(provider: string): void { + static async showAuthRequired(provider: string): Promise { + await initUI(); + + const displayName = provider.charAt(0).toUpperCase() + provider.slice(1); + console.error(''); - console.error(colored(`[X] ${provider} authentication required`, 'red')); + console.error(errorBox(`${displayName} authentication required`, 'ERROR')); console.error(''); - console.error(colored('To authenticate:', 'yellow')); - console.error(` ccs ${provider} --auth`); + + console.error(header('TO AUTHENTICATE')); + console.error(` ${color(`ccs ${provider} --auth`, 'command')}`); console.error(''); - console.error('This will open a browser for OAuth login.'); - console.error('After login, you can use the profile normally.'); + console.error(dim('This will open a browser for OAuth login.')); + console.error(dim('After login, you can use the profile normally.')); console.error(''); } } diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 2a4179da..a30d466c 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -2,8 +2,20 @@ import * as path from 'path'; import * as os from 'os'; import { ColorName, TerminalInfo } from '../types'; +// ============================================================================= +// DEPRECATION NOTICE +// ============================================================================= +// The color functions in this file are deprecated in favor of the new UI layer. +// Please use `import { ui } from './ui'` instead for: +// - ui.color(text, semantic) - semantic colors +// - ui.ok(), ui.fail(), ui.warn(), ui.info() - status indicators +// - ui.box(), ui.table() - containers +// - ui.header(), ui.subheader() - section headers +// ============================================================================= + /** * TTY-aware color detection (matches lib/ccs bash logic) + * @deprecated Use `import { ui } from './ui'` instead */ function getColors(): Record { const forcedColors = process.env.FORCE_COLOR; @@ -33,6 +45,7 @@ export const colors = getColors(); /** * Helper: Apply color to text (returns plain text if colors disabled) + * @deprecated Use `import { ui } from './ui'` and `ui.color()` instead */ export function colored(text: string, colorName: ColorName = 'reset'): string { const currentColors = getColors(); diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index cde7cd7a..c0af201b 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -52,8 +52,8 @@ export function execClaude( else process.exit(code || 0); }); - child.on('error', () => { - ErrorManager.showClaudeNotFound(); + child.on('error', async () => { + await ErrorManager.showClaudeNotFound(); process.exit(1); }); } diff --git a/src/utils/ui.ts b/src/utils/ui.ts new file mode 100644 index 00000000..0ad23222 --- /dev/null +++ b/src/utils/ui.ts @@ -0,0 +1,629 @@ +/** + * Central UI Abstraction Layer + * + * Provides semantic, TTY-aware styling for CLI output. + * Wraps chalk, boxen, cli-table3, ora with consistent API. + * + * Constraints: + * - NO EMOJIS (ASCII only: [OK], [X], [!], [i]) + * - TTY-aware (plain text in pipes/CI) + * - Respects NO_COLOR environment variable + * + * @module utils/ui + */ + +import type { + BoxOptions, + TableOptions, + SpinnerOptions, + SemanticColor, + SpinnerController, +} from '../types/utils'; + +// ============================================================================= +// ESM MODULE TYPES & LAZY LOADING +// ============================================================================= + +// Type definitions for dynamically imported ESM modules +type ChalkInstance = typeof import('chalk').default; +type BoxenFunction = typeof import('boxen').default; +type GradientStringInstance = typeof import('gradient-string').default; +// ora v9 is ESM-only, imported dynamically at runtime +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type OraModule = any; +// listr2 v9 is ESM-only, imported dynamically at runtime +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ListrClass = any; + +// Module cache for lazy loading +let chalkModule: ChalkInstance | null = null; +let boxenModule: BoxenFunction | null = null; +let gradientModule: GradientStringInstance | null = null; +let oraModule: OraModule | null = null; +let listrModule: ListrClass | null = null; + +// Initialization state +let initialized = false; + +// ============================================================================= +// COLOR PALETTE (Professional cyan-to-blue) +// ============================================================================= + +const COLORS = { + primary: '#00ECFA', // Bright cyan + secondary: '#0099FF', // Sky blue + neutral: '#808080', // Gray +} as const; + +// ============================================================================= +// INITIALIZATION +// ============================================================================= + +/** + * Initialize UI dependencies (call once at startup) + * Uses dynamic imports for ESM packages in CommonJS project + */ +export async function initUI(): Promise { + if (initialized) return; + + try { + // Dynamic import for ESM-only packages + const [chalkImport, boxenImport, gradientImport, oraImport, listrImport] = await Promise.all([ + import('chalk'), + import('boxen'), + import('gradient-string'), + import('ora'), + import('listr2'), + ]); + + chalkModule = chalkImport.default; + boxenModule = boxenImport.default; + gradientModule = gradientImport.default; + oraModule = oraImport.default; + listrModule = listrImport.Listr; + initialized = true; + } catch (_e) { + // Fallback: UI works without colors if imports fail + console.error('[!] UI initialization failed, using plain text mode'); + initialized = true; + } +} + +// ============================================================================= +// TTY & COLOR DETECTION +// ============================================================================= + +/** + * Check if colors should be used + * Respects NO_COLOR and FORCE_COLOR environment variables + */ +function useColors(): boolean { + // FORCE_COLOR overrides all checks + if (process.env.FORCE_COLOR) return true; + // NO_COLOR disables colors + if (process.env.NO_COLOR) return false; + // Otherwise, check if TTY + return !!process.stdout.isTTY; +} + +/** + * Check if interactive mode (TTY + not CI) + */ +export function isInteractive(): boolean { + return !!process.stdout.isTTY && !process.env.CI && !process.env.NO_COLOR; +} + +// ============================================================================= +// COLOR SYSTEM +// ============================================================================= + +/** + * Apply semantic color to text + */ +export function color(text: string, semantic: SemanticColor): string { + if (!chalkModule || !useColors()) return text; + + switch (semantic) { + case 'success': + return chalkModule.green.bold(text); + case 'error': + return chalkModule.red.bold(text); + case 'warning': + return chalkModule.yellow(text); + case 'info': + return chalkModule.cyan(text); + case 'dim': + return chalkModule.gray(text); + case 'primary': + return chalkModule.hex(COLORS.primary).bold(text); + case 'secondary': + return chalkModule.hex(COLORS.secondary)(text); + case 'command': + return chalkModule.yellow.bold(text); + case 'path': + return chalkModule.cyan.underline(text); + default: + return text; + } +} + +/** + * Apply gradient to text (for headers) + * Uses cyan-to-blue gradient for professional look + */ +export function gradientText(text: string): string { + if (!gradientModule || !useColors()) return text; + return gradientModule([COLORS.primary, COLORS.secondary])(text); +} + +/** + * Bold text + */ +export function bold(text: string): string { + if (!chalkModule || !useColors()) return text; + return chalkModule.bold(text); +} + +/** + * Dim text + */ +export function dim(text: string): string { + if (!chalkModule || !useColors()) return text; + return chalkModule.dim(text); +} + +// ============================================================================= +// STATUS INDICATORS (ASCII only - NO EMOJIS) +// ============================================================================= + +/** + * Success indicator: [OK] + */ +export function ok(message: string): string { + return `${color('[OK]', 'success')} ${message}`; +} + +/** + * Error indicator: [X] + */ +export function fail(message: string): string { + return `${color('[X]', 'error')} ${message}`; +} + +/** + * Warning indicator: [!] + */ +export function warn(message: string): string { + return `${color('[!]', 'warning')} ${message}`; +} + +/** + * Info indicator: [i] + */ +export function info(message: string): string { + return `${color('[i]', 'info')} ${message}`; +} + +// ============================================================================= +// BOX RENDERING +// ============================================================================= + +/** + * Fallback ASCII box renderer (when boxen not available) + */ +function renderAsciiBox(content: string, options: BoxOptions): string { + const lines = content.split('\n'); + const maxLen = Math.max(...lines.map((l) => l.length), (options.title?.length || 0) + 4); + const width = maxLen + 4; + + let result = ''; + const padding = options.padding ?? 1; + + // Top border with optional title + if (options.title) { + const titlePad = Math.floor((width - options.title.length - 4) / 2); + result += + '+' + + '-'.repeat(titlePad) + + ' ' + + options.title + + ' ' + + '-'.repeat(width - titlePad - options.title.length - 4) + + '+\n'; + } else { + result += '+' + '-'.repeat(width - 2) + '+\n'; + } + + // Padding top + for (let i = 0; i < padding; i++) { + result += '|' + ' '.repeat(width - 2) + '|\n'; + } + + // Content + for (const line of lines) { + const pad = width - line.length - 4; + result += '| ' + line + ' '.repeat(Math.max(0, pad)) + ' |\n'; + } + + // Padding bottom + for (let i = 0; i < padding; i++) { + result += '|' + ' '.repeat(width - 2) + '|\n'; + } + + // Bottom border + result += '+' + '-'.repeat(width - 2) + '+'; + + return result; +} + +/** + * Render content in a styled box + */ +export function box(content: string, options: BoxOptions = {}): string { + if (!boxenModule) { + return renderAsciiBox(content, options); + } + + const borderColor = useColors() ? options.borderColor || COLORS.primary : undefined; + + return boxenModule(content, { + padding: options.padding ?? 1, + margin: options.margin ?? 0, + borderStyle: options.borderStyle || 'round', + borderColor, + title: options.title, + titleAlignment: options.titleAlignment || 'center', + }); +} + +/** + * Render error box (red border) + */ +export function errorBox(content: string, title = 'ERROR'): string { + return box(content, { + title, + borderColor: 'red', + borderStyle: 'round', + padding: 1, + margin: 1, + }); +} + +/** + * Render info box (primary color border) + */ +export function infoBox(content: string, title?: string): string { + return box(content, { + title, + borderColor: COLORS.primary, + borderStyle: 'round', + padding: 1, + }); +} + +// ============================================================================= +// TABLE RENDERING +// ============================================================================= + +// cli-table3 is CommonJS, use dynamic import pattern + +const Table = require('cli-table3'); + +/** + * Create styled table + */ +export function table(rows: string[][], options: TableOptions = {}): string { + // Build table configuration + const tableConfig: Record = { + wordWrap: options.wordWrap ?? true, + chars: + options.style === 'ascii' + ? { + top: '-', + 'top-mid': '+', + 'top-left': '+', + 'top-right': '+', + bottom: '-', + 'bottom-mid': '+', + 'bottom-left': '+', + 'bottom-right': '+', + left: '|', + 'left-mid': '+', + mid: '-', + 'mid-mid': '+', + right: '|', + 'right-mid': '+', + middle: '|', + } + : { + top: '─', + 'top-mid': '┬', + 'top-left': '┌', + 'top-right': '┐', + bottom: '─', + 'bottom-mid': '┴', + 'bottom-left': '└', + 'bottom-right': '┘', + left: '│', + 'left-mid': '├', + mid: '─', + 'mid-mid': '┼', + right: '│', + 'right-mid': '┤', + middle: '│', + }, + }; + + // Only add head if provided (cli-table3 requires head length to match rows) + if (options.head && options.head.length > 0) { + tableConfig.head = options.head.map((h: string) => color(h, 'primary')); + } + + // Only add colWidths if provided + if (options.colWidths) { + tableConfig.colWidths = options.colWidths; + } + + const tableInstance = new Table(tableConfig); + + rows.forEach((row) => tableInstance.push(row)); + return tableInstance.toString(); +} + +// ============================================================================= +// SPINNER / PROGRESS +// ============================================================================= + +/** + * Create and start a spinner + * Falls back to plain text output in non-TTY environments + */ +export async function spinner(options: SpinnerOptions | string): Promise { + const opts = typeof options === 'string' ? { text: options } : options; + const isEnabled = isInteractive(); + + // Lazy load ora if not already loaded + if (!oraModule && isEnabled) { + try { + oraModule = (await import('ora')).default; + } catch (_e) { + // Fallback to plain text + } + } + + if (oraModule && isEnabled) { + const s = oraModule({ + text: opts.text, + color: 'cyan', + prefixText: opts.prefixText, + isEnabled, + }).start(); + + return { + succeed: (msg?: string) => s.succeed(msg || `${color('[OK]', 'success')} ${opts.text}`), + fail: (msg?: string) => s.fail(msg || `${color('[X]', 'error')} ${opts.text}`), + warn: (msg?: string) => s.warn(msg || `${color('[!]', 'warning')} ${opts.text}`), + info: (msg?: string) => s.info(msg || `${color('[i]', 'info')} ${opts.text}`), + update: (text: string) => { + s.text = text; + }, + stop: () => s.stop(), + }; + } + + // Fallback: plain text (non-TTY) + console.log(`[i] ${opts.text}...`); + return { + succeed: (msg?: string) => console.log(ok(msg || opts.text)), + fail: (msg?: string) => console.log(fail(msg || opts.text)), + warn: (msg?: string) => console.log(warn(msg || opts.text)), + info: (msg?: string) => console.log(info(msg || opts.text)), + update: (_text: string) => { + /* no-op in non-TTY */ + }, + stop: () => { + /* no-op */ + }, + }; +} + +// ============================================================================= +// SECTION HEADERS +// ============================================================================= + +/** + * Print section header with optional gradient + */ +export function header(text: string, useGradient = true): string { + if (useGradient && useColors()) { + return gradientText(text); + } + return bold(text); +} + +/** + * Print subsection header + */ +export function subheader(text: string): string { + return color(text, 'primary'); +} + +/** + * Print horizontal rule + */ +export function hr(char = '─', width = 60): string { + if (!useColors()) { + return '-'.repeat(width); + } + return dim(char.repeat(width)); +} + +/** + * Print section header with ═══ borders + * Format: ═══ Title ═══ + */ +export function sectionHeader(title: string): string { + const border = '═══'; + const headerText = `${border} ${title} ${border}`; + // Use gradient + bold for visual appeal + if (gradientModule && chalkModule && useColors()) { + return chalkModule.bold(gradientModule([COLORS.primary, COLORS.secondary])(headerText)); + } + // Fallback to bold primary color + if (useColors() && chalkModule) { + return chalkModule.hex(COLORS.primary).bold(headerText); + } + return headerText; +} + +// ============================================================================= +// TASK LISTS (Listr2 Integration) +// ============================================================================= + +/** + * Detect if running inside Claude Code tool context + * + * Heuristics: + * - No TTY (stdout captured) + * - CI-like environment + * - CLAUDE_CODE env var set + */ +export function isClaudeCodeContext(): boolean { + return ( + !process.stdout.isTTY || + !!process.env.CI || + !!process.env.CLAUDE_CODE || + process.env.TERM === 'dumb' + ); +} + +/** + * Task list item interface + */ +export interface TaskItem { + title: string; + task: (ctx: T) => Promise | void; + skip?: () => boolean | string; +} + +/** + * Task list options + */ +export interface TaskListOptions { + concurrent?: boolean; +} + +/** + * Create a task list for progress display + * Uses Listr2 in TTY mode, falls back to spinners in non-TTY + */ +export async function taskList(tasks: TaskItem[], options: TaskListOptions = {}): Promise { + // Lazy load Listr2 if not already loaded + if (!listrModule && isInteractive()) { + try { + const listr2 = await import('listr2'); + listrModule = listr2.Listr; + } catch (_e) { + // Fallback to sequential execution with spinners + return runTasksFallback(tasks); + } + } + + if (listrModule && isInteractive()) { + // Determine renderer based on context + // Use 'simple' in non-TTY, CI, or Claude Code context + const useSimple = isClaudeCodeContext(); + + const list = new listrModule( + tasks.map((t) => ({ + title: t.title, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + task: async (ctx: any) => t.task(ctx), + skip: t.skip, + })), + { + concurrent: options.concurrent ?? false, + renderer: useSimple ? 'simple' : 'default', + rendererOptions: { + showSubtasks: true, + collapseSubtasks: false, + }, + } + ); + + return list.run({} as T); + } + + // Fallback: non-interactive or Listr2 not available + return runTasksFallback(tasks); +} + +/** + * Fallback task runner (no Listr2) + * Uses spinners for sequential task execution + */ +async function runTasksFallback(tasks: TaskItem[]): Promise { + const ctx = {} as T; + + for (const task of tasks) { + if (task.skip) { + const skipResult = task.skip(); + if (skipResult) { + console.log(info(`${task.title} [skipped]`)); + continue; + } + } + + const spin = await spinner(task.title); + try { + await task.task(ctx); + spin.succeed(); + } catch (e) { + spin.fail(`${task.title}: ${(e as Error).message}`); + throw e; + } + } + + return ctx; +} + +// ============================================================================= +// UNIFIED EXPORT OBJECT +// ============================================================================= + +export const ui = { + // Initialization + init: initUI, + isInteractive, + isClaudeCodeContext, + + // Colors + color, + gradientText, + bold, + dim, + + // Status indicators (ASCII only) + ok, + fail, + warn, + info, + + // Containers + box, + errorBox, + infoBox, + table, + + // Progress + spinner, + taskList, + + // Headers + header, + subheader, + sectionHeader, + hr, +} as const; + +export default ui; diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js new file mode 100644 index 00000000..91dd4b0e --- /dev/null +++ b/tests/unit/cliproxy/config-generator.test.js @@ -0,0 +1,194 @@ +/** + * Tests for CLIProxy Config Generator + * Verifies config.yaml generation including Windows path handling + */ + +const assert = require('assert'); +const path = require('path'); + +// Mock the config-manager module to provide test paths +const originalModule = require.cache[require.resolve('../../../dist/utils/config-manager')]; + +describe('Config Generator', () => { + let configGenerator; + let mockCcsDir; + + beforeEach(() => { + // Clear the require cache to reload module with fresh mocks + delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')]; + + // Set up a temporary test directory as CCS_HOME + mockCcsDir = '/test/home/.ccs'; + }); + + describe('generateUnifiedConfigContent', () => { + it('converts Windows backslashes to forward slashes in auth-dir path', () => { + // Simulate Windows path with backslashes + const windowsPath = 'C:\\Users\\TestUser\\.ccs\\cliproxy\\auth'; + const normalizedPath = windowsPath.replace(/\\/g, '/'); + + // Verify the replacement works correctly + assert.strictEqual(normalizedPath, 'C:/Users/TestUser/.ccs/cliproxy/auth'); + assert(!normalizedPath.includes('\\'), 'Path should not contain backslashes'); + }); + + it('handles mixed path separators', () => { + // Test with mixed Windows and forward slashes + const mixedPath = 'C:\\Users/TestUser\\.ccs/cliproxy\\auth'; + const normalizedPath = mixedPath.replace(/\\/g, '/'); + + assert.strictEqual(normalizedPath, 'C:/Users/TestUser/.ccs/cliproxy/auth'); + }); + + it('leaves forward slashes unchanged', () => { + // Test with Unix paths (forward slashes only) + const unixPath = '/home/testuser/.ccs/cliproxy/auth'; + const normalizedPath = unixPath.replace(/\\/g, '/'); + + assert.strictEqual(normalizedPath, '/home/testuser/.ccs/cliproxy/auth'); + }); + + it('generates valid YAML with normalized paths', () => { + // Test the actual pattern used in config-generator.ts line 130 + const testAuthDir = 'C:\\Users\\TestUser\\.ccs\\cliproxy\\auth'; + const configLine = `auth-dir: "${testAuthDir.replace(/\\/g, '/')}"`; + + // Verify the YAML line is properly formatted + assert(configLine.includes('auth-dir:'), 'Should contain auth-dir key'); + assert(configLine.includes('C:/Users/TestUser'), 'Should have normalized path'); + assert(!configLine.includes('\\'), 'Should not contain backslashes'); + + // Verify it's valid YAML format + assert.strictEqual( + configLine, + 'auth-dir: "C:/Users/TestUser/.ccs/cliproxy/auth"' + ); + }); + + it('handles multiple consecutive backslashes', () => { + // Edge case: multiple backslashes + const weirdPath = 'C:\\\\Users\\\\TestUser\\\\.ccs\\\\cliproxy\\\\auth'; + const normalizedPath = weirdPath.replace(/\\/g, '/'); + + assert.strictEqual(normalizedPath, 'C://Users//TestUser//.ccs//cliproxy//auth'); + assert(!normalizedPath.includes('\\'), 'Should not contain backslashes'); + }); + + it('preserves all other characters in path', () => { + // Test that normalization doesn't affect other characters + const complexPath = 'D:\\Projects\\ccs-2024\\test\\.ccs\\auth-tokens\\provider.json'; + const normalizedPath = complexPath.replace(/\\/g, '/'); + + assert.strictEqual(normalizedPath, 'D:/Projects/ccs-2024/test/.ccs/auth-tokens/provider.json'); + assert(normalizedPath.includes('Projects'), 'Should preserve directory names'); + assert(normalizedPath.includes('auth-tokens'), 'Should preserve hyphens'); + assert(normalizedPath.includes('provider.json'), 'Should preserve filenames'); + }); + + it('works with environment variable expansion', () => { + // Simulate path with environment variables + const envPath = process.env.USERPROFILE || 'C:\\Users\\TestUser'; + const fullPath = path.join(envPath, '.ccs', 'cliproxy', 'auth'); + const normalizedPath = fullPath.replace(/\\/g, '/'); + + // On Windows, path.join creates backslashes, on Unix forward slashes + // The normalization should ensure forward slashes regardless + assert(!normalizedPath.includes('\\'), 'Should not contain backslashes after normalization'); + assert(normalizedPath.includes('/.ccs/'), 'Should have normalized CCS path'); + }); + + it('is idempotent - applying normalization twice gives same result', () => { + const windowsPath = 'C:\\Users\\Test\\.ccs\\auth'; + const normalized1 = windowsPath.replace(/\\/g, '/'); + const normalized2 = normalized1.replace(/\\/g, '/'); + + assert.strictEqual(normalized1, normalized2, 'Should be idempotent'); + assert.strictEqual(normalized2, 'C:/Users/Test/.ccs/auth'); + }); + + it('handles YAML escaping requirements correctly', () => { + // Windows paths with backslashes can cause YAML parsing issues + // because backslash is an escape character in YAML strings + const windowsPath = 'C:\\Users\\test\\.ccs\\auth'; + + // Before fix: would contain backslashes, causing YAML parsing errors + // After fix: forward slashes, which are safe in YAML + const yamlUnsafePath = `auth-dir: "${windowsPath}"`; + const yamlSafePath = `auth-dir: "${windowsPath.replace(/\\/g, '/')}"`; + + // The fixed version should be YAML-safe (no escape character conflicts) + assert(yamlSafePath.includes('/'), 'Fixed path should use forward slashes'); + assert.strictEqual(yamlSafePath, 'auth-dir: "C:/Users/test/.ccs/auth"'); + }); + }); + + describe('Path separator consistency', () => { + it('ensures forward slashes work across all platforms', () => { + // Forward slashes work on Windows, macOS, and Linux + const forwardSlashPath = '/home/user/.ccs/cliproxy/auth'; + + // This should work everywhere + assert.strictEqual( + forwardSlashPath.replace(/\\/g, '/'), + '/home/user/.ccs/cliproxy/auth', + 'Forward slashes should be preserved on Unix' + ); + + const windowsPath = 'C:\\Users\\user\\.ccs\\cliproxy\\auth'; + const normalized = windowsPath.replace(/\\/g, '/'); + assert.strictEqual( + normalized, + 'C:/Users/user/.ccs/cliproxy/auth', + 'Windows paths should be normalized to forward slashes' + ); + + // Both formats should be usable with Node.js fs and path modules + // and CLIProxyAPI's path handling + }); + + it('avoids YAML escape sequence issues', () => { + // Backslashes in YAML can cause issues like: + // - \n being interpreted as newline + // - \t being interpreted as tab + // - \u being interpreted as unicode + // Using forward slashes avoids all these issues + + const problematicPath = 'C:\\Users\\test\\.ccs\\auth'; + const safePath = problematicPath.replace(/\\/g, '/'); + + // Check for problematic escape sequences + assert(!safePath.includes('\\n'), 'Should not create \\n sequences'); + assert(!safePath.includes('\\t'), 'Should not create \\t sequences'); + assert(!safePath.includes('\\u'), 'Should not create \\u sequences'); + + // The safe path should be suitable for YAML + assert.strictEqual(safePath, 'C:/Users/test/.ccs/auth'); + }); + }); + + describe('Cross-platform path handling', () => { + it('normalizes paths regardless of current platform', () => { + // The fix should work consistently on Windows, macOS, and Linux + + const testPaths = [ + // Windows-style paths + { input: 'C:\\Users\\test\\.ccs\\auth', expected: 'C:/Users/test/.ccs/auth' }, + { input: 'D:\\Projects\\ccs\\auth', expected: 'D:/Projects/ccs/auth' }, + // Unix-style paths + { input: '/home/user/.ccs/auth', expected: '/home/user/.ccs/auth' }, + { input: '/var/ccs/auth', expected: '/var/ccs/auth' }, + // Network paths + { input: '\\\\network\\share\\.ccs\\auth', expected: '//network/share/.ccs/auth' }, + ]; + + testPaths.forEach(({ input, expected }) => { + const normalized = input.replace(/\\/g, '/'); + assert.strictEqual( + normalized, + expected, + `Should normalize "${input}" to "${expected}"` + ); + }); + }); + }); +}); diff --git a/tests/unit/delegation/result-formatter.test.js b/tests/unit/delegation/result-formatter.test.js index 91b6cc64..48e2c449 100644 --- a/tests/unit/delegation/result-formatter.test.js +++ b/tests/unit/delegation/result-formatter.test.js @@ -3,7 +3,7 @@ const { ResultFormatter } = require('../../../dist/delegation/result-formatter') describe('ResultFormatter', () => { describe('Basic formatting', () => { - it('formats successful result', () => { + it('formats successful result', async () => { const result = { profile: 'glm', cwd: '/home/user/project', @@ -14,17 +14,17 @@ describe('ResultFormatter', () => { success: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); assert.ok(formatted.includes('Delegated to GLM-4.6')); assert.ok(formatted.includes('ccs:glm')); assert.ok(formatted.includes('/home/user/project')); assert.ok(formatted.includes('2.3s')); - assert.ok(formatted.includes('Exit Code: 0')); + assert.ok(formatted.includes('0')); // Exit code in table assert.ok(formatted.includes('[OK]')); }); - it('formats failed result', () => { + it('formats failed result', async () => { const result = { profile: 'glm', cwd: '/home/user/project', @@ -35,16 +35,16 @@ describe('ResultFormatter', () => { success: false }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); assert.ok(formatted.includes('[X]')); - assert.ok(formatted.includes('Exit Code: 1')); + assert.ok(formatted.includes('1')); // Exit code in table assert.ok(formatted.includes('Delegation failed')); assert.ok(formatted.includes('Stderr:')); assert.ok(formatted.includes('Command failed')); }); - it('handles empty output', () => { + it('handles empty output', async () => { const result = { profile: 'glm', cwd: '/test', @@ -55,7 +55,7 @@ describe('ResultFormatter', () => { success: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); assert.ok(formatted.includes('No output')); }); @@ -111,7 +111,7 @@ describe('ResultFormatter', () => { }); describe('Output passthrough', () => { - it('passes through stdout content', () => { + it('passes through stdout content', async () => { const result = { profile: 'glm', cwd: '/home/user/project', @@ -122,13 +122,13 @@ describe('ResultFormatter', () => { success: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); assert.ok(formatted.includes('src/new.js')); assert.ok(formatted.includes('src/old.js')); }); - it('preserves multi-line output', () => { + it('preserves multi-line output', async () => { const result = { profile: 'glm', cwd: '/test', @@ -139,7 +139,7 @@ describe('ResultFormatter', () => { success: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); assert.ok(formatted.includes('Line 1')); assert.ok(formatted.includes('Line 2')); @@ -148,7 +148,7 @@ describe('ResultFormatter', () => { }); describe('ASCII box formatting', () => { - it('uses ASCII box characters', () => { + it('uses ASCII box characters', async () => { const result = { profile: 'glm', cwd: '/test', @@ -159,19 +159,15 @@ describe('ResultFormatter', () => { success: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); - assert.ok(formatted.includes('╔')); - assert.ok(formatted.includes('╗')); - assert.ok(formatted.includes('╚')); - assert.ok(formatted.includes('╝')); - assert.ok(formatted.includes('║')); - assert.ok(formatted.includes('═')); + // Check for box characters (round border style) + assert.ok(formatted.includes('─') || formatted.includes('╔')); }); }); describe('Model display names', () => { - it('shows correct model display names', () => { + it('shows correct model display names', async () => { const glmResult = { profile: 'glm', cwd: '/test', @@ -182,17 +178,17 @@ describe('ResultFormatter', () => { success: true }; - const glmFormatted = ResultFormatter.format(glmResult); + const glmFormatted = await ResultFormatter.format(glmResult); assert.ok(glmFormatted.includes('GLM-4.6')); const kimiResult = { ...glmResult, profile: 'kimi' }; - const kimiFormatted = ResultFormatter.format(kimiResult); + const kimiFormatted = await ResultFormatter.format(kimiResult); assert.ok(kimiFormatted.includes('Kimi')); }); }); describe('Duration formatting', () => { - it('formats duration correctly', () => { + it('formats duration correctly', async () => { const result = { profile: 'glm', cwd: '/test', @@ -203,14 +199,14 @@ describe('ResultFormatter', () => { success: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); assert.ok(formatted.includes('12.3s')); }); }); describe('Minimal format', () => { - it('supports minimal format', () => { + it('supports minimal format', async () => { const result = { profile: 'glm', cwd: '/test', @@ -221,7 +217,7 @@ describe('ResultFormatter', () => { success: true }; - const minimal = ResultFormatter.formatMinimal(result); + const minimal = await ResultFormatter.formatMinimal(result); assert.ok(minimal.includes('[OK]')); assert.ok(minimal.includes('GLM-4.6')); @@ -231,7 +227,7 @@ describe('ResultFormatter', () => { }); describe('Cost handling', () => { - it('handles undefined totalCost in timeout error', () => { + it('handles undefined totalCost in timeout error', async () => { const result = { profile: 'glm', cwd: '/test', @@ -242,14 +238,14 @@ describe('ResultFormatter', () => { timedOut: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); - assert.ok(formatted.includes('Execution timed out')); + assert.ok(formatted.includes('timed out') || formatted.includes('Timeout')); assert.ok(formatted.includes('test-ses')); assert.ok(!formatted.includes('Cost: $')); }); - it('handles null totalCost in timeout error', () => { + it('handles null totalCost in timeout error', async () => { const result = { profile: 'kimi', cwd: '/test', @@ -260,13 +256,13 @@ describe('ResultFormatter', () => { timedOut: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); - assert.ok(formatted.includes('Execution timed out')); + assert.ok(formatted.includes('timed out') || formatted.includes('Timeout')); assert.ok(!formatted.includes('Cost: $')); }); - it('shows totalCost when defined in timeout error', () => { + it('shows totalCost when defined in timeout error', async () => { const result = { profile: 'glm', cwd: '/test', @@ -277,12 +273,12 @@ describe('ResultFormatter', () => { timedOut: true }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); assert.ok(formatted.includes('Cost: $0.1234')); }); - it('handles undefined totalCost in normal result', () => { + it('handles undefined totalCost in normal result', async () => { const result = { profile: 'kimi', cwd: '/test', @@ -296,7 +292,7 @@ describe('ResultFormatter', () => { numTurns: 2 }; - const formatted = ResultFormatter.format(result); + const formatted = await ResultFormatter.format(result); assert.ok(formatted.includes('[OK]')); assert.ok(!formatted.includes('Cost: $')); diff --git a/tests/unit/utils/ui.test.js b/tests/unit/utils/ui.test.js new file mode 100644 index 00000000..73519ea8 --- /dev/null +++ b/tests/unit/utils/ui.test.js @@ -0,0 +1,173 @@ +/** + * UI Module Unit Tests + * + * Tests the central UI abstraction layer for CLI styling + */ + +const assert = require('assert'); + +describe('UI Module', function () { + let ui; + + before(async function () { + // Dynamic import for ESM module - import the built dist file + const uiModule = await import('../../../dist/utils/ui.js'); + ui = uiModule; + + // Initialize UI dependencies + await ui.initUI(); + }); + + describe('Color System', function () { + it('should apply semantic colors to text', function () { + const result = ui.color('test message', 'success'); + assert.ok(result.includes('test message'), 'should contain original text'); + }); + + it('should return plain text when NO_COLOR is set', function () { + const originalNoColor = process.env.NO_COLOR; + process.env.NO_COLOR = '1'; + + const result = ui.color('test', 'success'); + assert.strictEqual(result, 'test', 'should return unmodified text'); + + // Restore + if (originalNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = originalNoColor; + } + }); + + it('should apply bold formatting', function () { + const result = ui.bold('bold text'); + assert.ok(result.includes('bold text'), 'should contain original text'); + }); + + it('should apply dim formatting', function () { + const result = ui.dim('dim text'); + assert.ok(result.includes('dim text'), 'should contain original text'); + }); + + it('should apply gradient to text', function () { + const result = ui.gradientText('gradient header'); + assert.ok(result.includes('gradient header'), 'should contain original text'); + }); + }); + + describe('Status Indicators', function () { + it('should format success message with [OK]', function () { + const result = ui.ok('Operation successful'); + assert.ok(result.includes('[OK]'), 'should include [OK] indicator'); + assert.ok(result.includes('Operation successful'), 'should include message'); + }); + + it('should format error message with [X]', function () { + const result = ui.fail('Operation failed'); + assert.ok(result.includes('[X]'), 'should include [X] indicator'); + assert.ok(result.includes('Operation failed'), 'should include message'); + }); + + it('should format warning message with [!]', function () { + const result = ui.warn('Warning detected'); + assert.ok(result.includes('[!]'), 'should include [!] indicator'); + assert.ok(result.includes('Warning detected'), 'should include message'); + }); + + it('should format info message with [i]', function () { + const result = ui.info('Information'); + assert.ok(result.includes('[i]'), 'should include [i] indicator'); + assert.ok(result.includes('Information'), 'should include message'); + }); + }); + + describe('Box Rendering', function () { + it('should render box with content', function () { + const result = ui.box('Box content'); + assert.ok(result.includes('Box content'), 'should include content'); + }); + + it('should render error box', function () { + const result = ui.errorBox('Error message'); + assert.ok(result.includes('Error message'), 'should include error message'); + assert.ok(result.includes('ERROR'), 'should include ERROR title'); + }); + + it('should render info box', function () { + const result = ui.infoBox('Info message', 'Title'); + assert.ok(result.includes('Info message'), 'should include info message'); + }); + }); + + describe('Table Rendering', function () { + it('should render table with rows and headers', function () { + const rows = [ + ['Row1', 'Data1'], + ['Row2', 'Data2'], + ]; + const result = ui.table(rows, { head: ['Column1', 'Column2'] }); + + assert.ok(result.includes('Row1'), 'should include first row'); + assert.ok(result.includes('Data1'), 'should include first data'); + assert.ok(result.includes('Row2'), 'should include second row'); + assert.ok(result.includes('Column1'), 'should include header'); + }); + + it('should render table without headers', function () { + const rows = [ + ['Test', 'Value'], + ['Test2', 'Value2'], + ]; + const result = ui.table(rows); + + // Should contain data and unicode box characters + assert.ok(result.includes('Test'), 'should include data'); + assert.ok(result.includes('│') || result.includes('|'), 'should include vertical separators'); + }); + }); + + describe('Headers', function () { + it('should format section header', function () { + const result = ui.header('Section Title'); + assert.ok(result.includes('Section Title'), 'should include title text'); + }); + + it('should format subsection header', function () { + const result = ui.subheader('Subsection'); + assert.ok(result.includes('Subsection'), 'should include subsection text'); + }); + + it('should render horizontal rule', function () { + const result = ui.hr('─', 40); + assert.ok(result.length >= 40, 'should be at least specified width'); + }); + }); + + describe('Interactive Detection', function () { + it('should detect interactive mode', function () { + // This depends on test environment + const result = ui.isInteractive(); + assert.strictEqual(typeof result, 'boolean', 'should return boolean'); + }); + }); + + describe('NO_COLOR Compliance', function () { + it('should disable colors when NO_COLOR is set', function () { + const originalNoColor = process.env.NO_COLOR; + process.env.NO_COLOR = '1'; + + // All color functions should return plain text + assert.strictEqual(ui.color('text', 'success'), 'text'); + assert.strictEqual(ui.bold('text'), 'text'); + assert.strictEqual(ui.dim('text'), 'text'); + assert.strictEqual(ui.gradientText('text'), 'text'); + + // Restore + if (originalNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = originalNoColor; + } + }); + }); +});