diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 80ae031a..48fd91aa 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -6,13 +6,15 @@ on: jobs: release: - # Skip if commit message contains [skip ci] or is a release commit + # Skip if commit message contains [skip ci] if: "!contains(github.event.head_commit.message, '[skip ci]')" runs-on: ubuntu-latest permissions: contents: write issues: write + pull-requests: write + id-token: write steps: - name: Checkout @@ -43,88 +45,59 @@ jobs: - name: Validate (typecheck + lint + tests) run: bun run validate - - name: Bump dev version - id: bump - run: | - CURRENT=$(cat VERSION) - PKG_NAME=$(jq -r '.name' package.json) - - # Extract base version - if [[ "$CURRENT" =~ ^([0-9]+\.[0-9]+\.[0-9]+)(-dev\.([0-9]+))?$ ]]; then - BASE="${BASH_REMATCH[1]}" - else - echo "Invalid version format: $CURRENT" - exit 1 - fi - - # Find highest published dev version for this base - LATEST_DEV=$(npm view "${PKG_NAME}" versions --json 2>/dev/null | \ - jq -r '.[]' | \ - grep "^${BASE}-dev\." | \ - sed "s/${BASE}-dev\.//" | \ - sort -n | \ - tail -1) - - if [[ -z "$LATEST_DEV" ]]; then - NEW_DEV=1 - else - NEW_DEV=$((LATEST_DEV + 1)) - fi - - NEW_VERSION="${BASE}-dev.${NEW_DEV}" - - echo "current=$CURRENT" >> $GITHUB_OUTPUT - echo "new=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "Bumping: $CURRENT -> $NEW_VERSION" - - - name: Update version files - run: | - NEW_VERSION="${{ steps.bump.outputs.new }}" - - # Update VERSION file - echo "$NEW_VERSION" > VERSION - - # Update package.json - jq --arg v "$NEW_VERSION" '.version = $v' package.json > package.json.tmp - mv package.json.tmp package.json - - # Update installers - sed -i "s/^VERSION=.*/VERSION=\"$NEW_VERSION\"/" installers/install.sh - sed -i "s/^\$Version = .*/\$Version = \"$NEW_VERSION\"/" installers/install.ps1 - - - name: Publish to npm + - name: Release + id: release env: + HUSKY: 0 + GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} + GH_TOKEN: ${{ secrets.PAT_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: npm publish --tag dev + run: | + OUTPUT=$(bunx semantic-release 2>&1) || true + echo "$OUTPUT" + if echo "$OUTPUT" | grep -q "Published release"; then + echo "released=true" >> $GITHUB_OUTPUT + else + echo "released=false" >> $GITHUB_OUTPUT + fi + + - name: Notify Discord + if: success() && steps.release.outputs.released == 'true' + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: | + if [ -z "$DISCORD_WEBHOOK_URL" ]; then + echo "DISCORD_WEBHOOK_URL not set, skipping" + exit 0 + fi + node scripts/send-discord-release.cjs dev "$DISCORD_WEBHOOK_URL" - name: Tag resolved issues + if: success() && steps.release.outputs.released == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - NEW_VERSION="${{ steps.bump.outputs.new }}" + # Get version from package.json (updated by semantic-release) + VERSION=$(jq -r '.version' package.json) - # Find commits since last dev release (look for version bump commits) - LAST_RELEASE_COMMIT=$(git log --oneline --grep="chore(release):" -n 2 | tail -1 | cut -d' ' -f1) - - if [[ -n "$LAST_RELEASE_COMMIT" ]]; then - RANGE="${LAST_RELEASE_COMMIT}..HEAD" - else - RANGE="HEAD~20..HEAD" - fi + # Find commits since last release + LAST_RELEASE=$(git log --oneline --grep="chore(release):" -n 2 | tail -1 | cut -d' ' -f1) + RANGE="${LAST_RELEASE:-HEAD~20}..HEAD" echo "Checking commits in range: $RANGE" - # Extract issue numbers from commits + # Extract issue numbers ISSUES=$(git log $RANGE --pretty=format:"%s %b" | \ grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | \ grep -oE "#[0-9]+" | sort -u || true) if [[ -z "$ISSUES" ]]; then - echo "No linked issues found in commits" + echo "No linked issues found" exit 0 fi - # Create labels if they don't exist + # Create label if needed gh label create "released-dev" \ --color "1d76db" \ --description "Fix available in @dev npm channel" \ @@ -133,27 +106,13 @@ jobs: for ISSUE in $ISSUES; do NUM=${ISSUE#\#} - # Skip if already tagged (avoid spam) + # Skip if already tagged if gh issue view "$NUM" --repo "${{ github.repository }}" --json labels --jq '.labels[].name' | grep -q "released-dev"; then - echo "Issue #$NUM already has released-dev label, skipping" + echo "Issue #$NUM already tagged, skipping" continue fi - echo "Tagging issue #$NUM as released-dev" - - # Add comment - gh issue comment "$NUM" --repo "${{ github.repository }}" --body ":test_tube: Available in \`$NEW_VERSION\`. Install: \`npm i @kaitranntt/ccs@dev\`" || true - - # Add released-dev label + echo "Tagging issue #$NUM" + gh issue comment "$NUM" --repo "${{ github.repository }}" --body "[i] Available in \`$VERSION\`. Install: \`npm i @kaitranntt/ccs@dev\`" || true gh issue edit "$NUM" --add-label "released-dev" --repo "${{ github.repository }}" || true done - - - name: Commit version bump - env: - HUSKY: 0 - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add VERSION package.json installers/install.sh installers/install.ps1 - git commit -m "chore(release): ${{ steps.bump.outputs.new }} [skip ci]" - git push origin dev diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ca53cf5..0e1f2719 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,10 +45,29 @@ jobs: run: bun run validate - name: Release + id: release env: HUSKY: 0 GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} GH_TOKEN: ${{ secrets.PAT_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: bunx semantic-release + run: | + OUTPUT=$(bunx semantic-release 2>&1) || true + echo "$OUTPUT" + if echo "$OUTPUT" | grep -q "Published release"; then + echo "released=true" >> $GITHUB_OUTPUT + else + echo "released=false" >> $GITHUB_OUTPUT + fi + + - name: Notify Discord + if: success() && steps.release.outputs.released == 'true' + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: | + if [ -z "$DISCORD_WEBHOOK_URL" ]; then + echo "DISCORD_WEBHOOK_URL not set, skipping" + exit 0 + fi + node scripts/send-discord-release.cjs production "$DISCORD_WEBHOOK_URL" diff --git a/.releaserc.cjs b/.releaserc.cjs new file mode 100644 index 00000000..eff9aeab --- /dev/null +++ b/.releaserc.cjs @@ -0,0 +1,120 @@ +/** + * Semantic Release Configuration + * + * Branch-aware config: + * - dev branch: Uses dev release configuration (prerelease) + * - main branch: Uses production release configuration + */ + +const currentBranch = + process.env.GITHUB_REF_NAME || + process.env.GIT_BRANCH || + (process.env.GITHUB_REF && process.env.GITHUB_REF.replace('refs/heads/', '')) || + require('child_process').execSync('git rev-parse --abbrev-ref HEAD').toString().trim(); + +console.error(`[semantic-release config] Branch: ${currentBranch}`); + +// Shared plugin config +const commitAnalyzer = [ + '@semantic-release/commit-analyzer', + { + preset: 'conventionalcommits', + releaseRules: [ + { type: 'docs', scope: 'README', release: 'patch' }, + { type: 'refactor', release: 'patch' }, + { type: 'style', release: 'patch' }, + ], + }, +]; + +const releaseNotesGenerator = [ + '@semantic-release/release-notes-generator', + { + preset: 'conventionalcommits', + presetConfig: { + types: [ + { type: 'feat', section: 'Features' }, + { type: 'fix', section: 'Bug Fixes' }, + { type: 'docs', section: 'Documentation' }, + { type: 'style', section: 'Styles' }, + { type: 'refactor', section: 'Code Refactoring' }, + { type: 'perf', section: 'Performance Improvements' }, + { type: 'test', section: 'Tests' }, + { type: 'build', section: 'Build System' }, + { type: 'ci', section: 'CI' }, + ], + }, + }, +]; + +// Dev release configuration +const devConfig = { + branches: [ + 'main', // Required even in dev config + { + name: 'dev', + prerelease: 'dev', + }, + ], + plugins: [ + commitAnalyzer, + releaseNotesGenerator, + [ + '@semantic-release/changelog', + { + changelogFile: 'CHANGELOG.md', + }, + ], + '@semantic-release/npm', + [ + '@semantic-release/github', + { + prerelease: true, + }, + ], + [ + '@semantic-release/git', + { + assets: ['CHANGELOG.md', 'package.json'], + message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}', + }, + ], + ], +}; + +// Production release configuration +const productionConfig = { + branches: ['main'], + plugins: [ + commitAnalyzer, + releaseNotesGenerator, + [ + '@semantic-release/changelog', + { + changelogFile: 'CHANGELOG.md', + }, + ], + '@semantic-release/npm', + [ + '@semantic-release/github', + { + successComment: + ':tada: This issue has been resolved in version ${nextRelease.version} :tada:\n\nThe release is available on:\n- [npm package (@latest)](https://www.npmjs.com/package/@kaitranntt/ccs)\n- [GitHub release](${releases[0].url})', + releasedLabels: ['released'], + }, + ], + [ + '@semantic-release/git', + { + assets: ['CHANGELOG.md', 'package.json'], + message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}', + }, + ], + ], +}; + +const config = currentBranch === 'dev' ? devConfig : productionConfig; + +console.error(`[semantic-release config] Using ${currentBranch === 'dev' ? 'DEV' : 'PRODUCTION'} config`); + +module.exports = config; diff --git a/.releaserc.json b/.releaserc.json deleted file mode 100644 index 1809e16b..00000000 --- a/.releaserc.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "branches": ["main"], - "plugins": [ - "@semantic-release/commit-analyzer", - "@semantic-release/release-notes-generator", - ["@semantic-release/changelog", { - "changelogFile": "CHANGELOG.md" - }], - "./scripts/sync-version-plugin.cjs", - ["@semantic-release/npm", { - "npmPublish": true - }], - ["@semantic-release/git", { - "assets": ["CHANGELOG.md", "package.json", "VERSION", "installers/install.sh", "installers/install.ps1"], - "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" - }], - ["@semantic-release/github", { - "successComment": ":tada: This issue has been resolved in version ${nextRelease.version} :tada:\n\nThe release is available on:\n- [npm package (@${nextRelease.channel || 'latest'})](https://www.npmjs.com/package/ccs-claude-code-switcher)\n- [GitHub release](${releases.filter(r => r.name === 'GitHub release').map(r => r.url)[0] || url})", - "failComment": false, - "releasedLabels": ["released"] - }] - ] -} diff --git a/CHANGELOG.md b/CHANGELOG.md index 24c42936..55dff2a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,75 @@ +## [6.6.0-dev.4](https://github.com/kaitranntt/ccs/compare/v6.6.0-dev.3...v6.6.0-dev.4) (2025-12-19) + +### Bug Fixes + +* **profiles:** prevent env var inheritance for settings-based profiles ([903bc10](https://github.com/kaitranntt/ccs/commit/903bc10fea11694474f772356f301b8e4b37298e)) + +## [6.6.0-dev.3](https://github.com/kaitranntt/ccs/compare/v6.6.0-dev.2...v6.6.0-dev.3) (2025-12-19) + +### Features + +* **cliproxy:** add getRemoteEnvVars for remote proxy mode ([f4a50d0](https://github.com/kaitranntt/ccs/commit/f4a50d006c1f6bd284fe743f9a322540763e1848)) +* **cliproxy:** add proxy config resolver with CLI flag support ([68a93f0](https://github.com/kaitranntt/ccs/commit/68a93f0500f396ebcc65cc133c1a444ae5a0f220)) +* **cliproxy:** add remote proxy client for health checks ([30d564c](https://github.com/kaitranntt/ccs/commit/30d564cda66a54c2ac12788559624cb0736cdeb3)) +* **cliproxy:** integrate remote proxy mode in executor ([bd1ff2f](https://github.com/kaitranntt/ccs/commit/bd1ff2f059d01d4371b2230d4902bc5ab210055e)) +* **config:** add proxy configuration types and schema ([eff2e2d](https://github.com/kaitranntt/ccs/commit/eff2e2d29f3f227c05103c252823fb9e040b6e49)) +* **config:** add proxy section to unified config loader ([1971744](https://github.com/kaitranntt/ccs/commit/197174441f6eeca5e3c98e88af43d91ee081f734)) +* **ui:** add Proxy settings tab to dashboard ([9a9ef98](https://github.com/kaitranntt/ccs/commit/9a9ef98542bb766087b711fc39e928e347ad9b86)) +* **web-server:** add proxy configuration API routes ([8decdfb](https://github.com/kaitranntt/ccs/commit/8decdfb515075b772970de7c85b34c31baf93754)) + +### Documentation + +* **cliproxy:** add remote proxy documentation ([196422c](https://github.com/kaitranntt/ccs/commit/196422cee1f7410d385581f2a28df3faa87d68e3)) + +### Styles + +* **ui:** use sidebar accent colors for proxy update button ([eeb6913](https://github.com/kaitranntt/ccs/commit/eeb6913d96fe1a9a0d8721627a07c7f772b67b88)) + +### Code Refactoring + +* rename proxy to cliproxy_server and update API routes ([8d8d4c2](https://github.com/kaitranntt/ccs/commit/8d8d4c248ad890413d5c4e7e72f9f2a16305f74f)) + +## [6.6.0-dev.2](https://github.com/kaitranntt/ccs/compare/v6.6.0-dev.1...v6.6.0-dev.2) (2025-12-19) + +### Bug Fixes + +* **ci:** remove sync-version.js that depends on deleted VERSION file ([18729c9](https://github.com/kaitranntt/ccs/commit/18729c9983ecd1f9d857b0de2753e99c675c624a)) + +## [6.6.0-dev.1](https://github.com/kaitranntt/ccs/compare/v6.5.0...v6.6.0-dev.1) (2025-12-19) + +### ⚠ BREAKING CHANGES + +* Native shell installers (curl/irm) no longer work. +Use `npm install -g @kaitranntt/ccs` instead. + +### Features + +* **ci:** add Discord notifications for releases ([ee76d66](https://github.com/kaitranntt/ccs/commit/ee76d663aec59a86a236156dbc163d0d291c0446)) +* **ci:** add semantic-release for dev branch with rich Discord notifications ([0f590c8](https://github.com/kaitranntt/ccs/commit/0f590c80d689c39cea7c94937ed398941dddb533)) +* **cleanup:** add age-based error log cleanup ([45207b4](https://github.com/kaitranntt/ccs/commit/45207b4e7f92c09d7464dd5c954718254ddfd43a)) +* **cliproxy:** set WRITABLE_PATH for log storage in ~/.ccs/cliproxy/ ([6b9396f](https://github.com/kaitranntt/ccs/commit/6b9396fbc6d464bc3e3d6d3bb639e70fe5306074)) +* **dashboard:** add error log viewer for CLIProxy diagnostics ([5b3d565](https://github.com/kaitranntt/ccs/commit/5b3d56548a8dfb2e6bb22e14b13f0fb038f2d1fb)), closes [#132](https://github.com/kaitranntt/ccs/issues/132) +* **global-env:** add global environment variables injection for third-party profiles ([5d34326](https://github.com/kaitranntt/ccs/commit/5d343260c7307c2d7ac8da92eb5f94c7f764d08c)) +* **ui:** add absolute path copy for error logs ([5d4f49e](https://github.com/kaitranntt/ccs/commit/5d4f49e4bb6f9748efa89e96c342dfae3e35d02b)) +* **ui:** add Stop and Restart buttons to ProxyStatusWidget ([c9ad0b0](https://github.com/kaitranntt/ccs/commit/c9ad0b077934ae8418d4e97b9b02a09044ff898b)) +* **ui:** add version sync timestamp to ProxyStatusWidget ([d43079b](https://github.com/kaitranntt/ccs/commit/d43079b72414d7b841a35a934ea39a91527f4172)) +* **ui:** redesign error logs monitor with split view layout ([8f47b87](https://github.com/kaitranntt/ccs/commit/8f47b8775f2c2493c05ee2be861ca3f8667cfc0e)) +* **ui:** show CLIProxyAPI update availability in dashboard ([96762a9](https://github.com/kaitranntt/ccs/commit/96762a9f6ee096570b2fe6136a4431e6ce1d1a47)) + +### Bug Fixes + +* **ci:** remove deprecated installer references from dev-release workflow ([4b969b6](https://github.com/kaitranntt/ccs/commit/4b969b6870aae6b5859b9a1be0cf98b9d537ce00)) +* **cliproxy:** prevent misleading update message when proxy is running ([2adc272](https://github.com/kaitranntt/ccs/commit/2adc272f278b1d80d160ad4d6e1f35e3b61cb156)), closes [#143](https://github.com/kaitranntt/ccs/issues/143) +* **error-logs-monitor:** properly handle status loading state ([1ef625e](https://github.com/kaitranntt/ccs/commit/1ef625ee863c517a5fbba21f16cf991bb77be7d7)) + +### Styles + +* **ui:** widen cliproxy sidebar from w-64 to w-80 ([248d970](https://github.com/kaitranntt/ccs/commit/248d970cba8671b7c20dc99f8d1a70e4fe113605)) + +### Code Refactoring + +* remove deprecated native shell installers ([126cffc](https://github.com/kaitranntt/ccs/commit/126cffc6dcf434abeee883a4109d3705cdb92a67)) + # [6.5.0](https://github.com/kaitranntt/ccs/compare/v6.4.0...v6.5.0) (2025-12-18) diff --git a/README.md b/README.md index bb69dc9c..0a6620c7 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,7 @@ See [docs/websearch.md](./docs/websearch.md) for detailed configuration and trou | OAuth Providers | [docs.ccs.kaitran.ca/providers/oauth-providers](https://docs.ccs.kaitran.ca/providers/oauth-providers) | | Multi-Account Claude | [docs.ccs.kaitran.ca/providers/claude-accounts](https://docs.ccs.kaitran.ca/providers/claude-accounts) | | API Profiles | [docs.ccs.kaitran.ca/providers/api-profiles](https://docs.ccs.kaitran.ca/providers/api-profiles) | +| Remote Proxy | [docs.ccs.kaitran.ca/features/remote-proxy](https://docs.ccs.kaitran.ca/features/remote-proxy) | | CLI Reference | [docs.ccs.kaitran.ca/reference/cli-commands](https://docs.ccs.kaitran.ca/reference/cli-commands) | | Architecture | [docs.ccs.kaitran.ca/reference/architecture](https://docs.ccs.kaitran.ca/reference/architecture) | | Troubleshooting | [docs.ccs.kaitran.ca/reference/troubleshooting](https://docs.ccs.kaitran.ca/reference/troubleshooting) | diff --git a/VERSION b/VERSION deleted file mode 100644 index f22d756d..00000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -6.5.0 diff --git a/bun.lock b/bun.lock index c147f881..a697e9ab 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,11 @@ "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", "@semantic-release/changelog": "^6.0.3", + "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/git": "^10.0.1", + "@semantic-release/github": "^12.0.2", + "@semantic-release/npm": "^13.1.3", + "@semantic-release/release-notes-generator": "^14.1.0", "@tailwindcss/vite": "^4.1.17", "@types/chokidar": "^2.1.7", "@types/express": "^4.17.21", @@ -31,6 +35,7 @@ "@typescript-eslint/eslint-plugin": "^8.48.0", "@typescript-eslint/parser": "^8.48.0", "@vitejs/plugin-react": "^5.1.1", + "conventional-changelog-conventionalcommits": "^9.1.0", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "husky": "^9.1.7", @@ -43,13 +48,13 @@ }, }, "packages": { - "@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="], + "@actions/core": ["@actions/core@2.0.1", "", { "dependencies": { "@actions/exec": "^2.0.0", "@actions/http-client": "^3.0.0" } }, "sha512-oBfqT3GwkvLlo1fjvhQLQxuwZCGTarTE5OuZ2Wg10hvhBj7LRIlF611WT4aZS6fDhO5ZKlY7lCAZTlpmyaHaeg=="], - "@actions/exec": ["@actions/exec@1.1.1", "", { "dependencies": { "@actions/io": "^1.0.1" } }, "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w=="], + "@actions/exec": ["@actions/exec@2.0.0", "", { "dependencies": { "@actions/io": "^2.0.0" } }, "sha512-k8ngrX2voJ/RIN6r9xB82NVqKpnMRtxDoiO+g3olkIUpQNqjArXrCQceduQZCQj3P3xm32pChRLqRrtXTlqhIw=="], - "@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + "@actions/http-client": ["@actions/http-client@3.0.0", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.28.5" } }, "sha512-1s3tXAfVMSz9a4ZEBkXXRQD4QhY3+GAsWSbaYpeknPOKEeyRiU3lH+bHiLMZdo2x/fIeQ/hscL1wCkDLVM2DZQ=="], - "@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="], + "@actions/io": ["@actions/io@2.0.0", "", {}, "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg=="], "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], @@ -305,7 +310,7 @@ "@semantic-release/github": ["@semantic-release/github@12.0.2", "", { "dependencies": { "@octokit/core": "^7.0.0", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-retry": "^8.0.0", "@octokit/plugin-throttling": "^11.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "debug": "^4.3.4", "dir-glob": "^3.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "issue-parser": "^7.0.0", "lodash-es": "^4.17.21", "mime": "^4.0.0", "p-filter": "^4.0.0", "tinyglobby": "^0.2.14", "undici": "^7.0.0", "url-join": "^5.0.0" }, "peerDependencies": { "semantic-release": ">=24.1.0" } }, "sha512-qyqLS+aSGH1SfXIooBKjs7mvrv0deg8v+jemegfJg1kq6ji+GJV8CO08VJDEsvjp3O8XJmTTIAjjZbMzagzsdw=="], - "@semantic-release/npm": ["@semantic-release/npm@13.1.2", "", { "dependencies": { "@actions/core": "^1.11.1", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", "nerf-dart": "^1.0.0", "normalize-url": "^8.0.0", "npm": "^11.6.2", "rc": "^1.2.8", "read-pkg": "^10.0.0", "registry-auth-token": "^5.0.0", "semver": "^7.1.2", "tempy": "^3.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-9rtshDTNlzYrC7uSBtB1vHqFzFZaNHigqkkCH5Ls4N/BSlVOenN5vtwHYxjAR4jf1hNvWSVwL4eIFTHONYckkw=="], + "@semantic-release/npm": ["@semantic-release/npm@13.1.3", "", { "dependencies": { "@actions/core": "^2.0.0", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", "nerf-dart": "^1.0.0", "normalize-url": "^8.0.0", "npm": "^11.6.2", "rc": "^1.2.8", "read-pkg": "^10.0.0", "registry-auth-token": "^5.0.0", "semver": "^7.1.2", "tempy": "^3.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-q7zreY8n9V0FIP1Cbu63D+lXtRAVAIWb30MH5U3TdrfXt6r2MIrWCY0whAImN53qNvSGp0Zt07U95K+Qp9GpEg=="], "@semantic-release/release-notes-generator": ["@semantic-release/release-notes-generator@14.1.0", "", { "dependencies": { "conventional-changelog-angular": "^8.0.0", "conventional-changelog-writer": "^8.0.0", "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.0.0", "debug": "^4.0.0", "get-stream": "^7.0.0", "import-from-esm": "^2.0.0", "into-stream": "^7.0.0", "lodash-es": "^4.17.21", "read-package-up": "^11.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-CcyDRk7xq+ON/20YNR+1I/jP7BYKICr1uKd1HHpROSnnTdGqOTburi4jcRiTYz0cpfhxSloQO3cGhnoot7IEkA=="], @@ -529,7 +534,7 @@ "conventional-changelog-angular": ["conventional-changelog-angular@8.1.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-GGf2Nipn1RUCAktxuVauVr1e3r8QrLP/B0lEUsFktmGqc3ddbQkhoJZHJctVU829U1c6mTSWftrVOCHaL85Q3w=="], - "conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@7.0.2", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w=="], + "conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@9.1.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-MnbEysR8wWa8dAEvbj5xcBgJKQlX/m0lhS8DsyAAWDHdfs2faDJxTgzRYlRYpXSe7UiKrIIlB4TrBKU9q9DgkA=="], "conventional-changelog-writer": ["conventional-changelog-writer@8.2.0", "", { "dependencies": { "conventional-commits-filter": "^5.0.0", "handlebars": "^4.7.7", "meow": "^13.0.0", "semver": "^7.5.2" }, "bin": { "conventional-changelog-writer": "dist/cli/index.js" } }, "sha512-Y2aW4596l9AEvFJRwFGJGiQjt2sBYTjPD18DdvxX9Vpz0Z7HQ+g1Z+6iYDAm1vR3QOJrDBkRHixHK/+FhkR6Pw=="], @@ -705,7 +710,7 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + "get-stream": ["get-stream@7.0.1", "", {}, "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ=="], "git-log-parser": ["git-log-parser@1.2.1", "", { "dependencies": { "argv-formatter": "~1.0.0", "spawn-error-forwarder": "~1.0.0", "split2": "~1.0.0", "stream-combiner2": "~1.1.1", "through2": "~2.0.0", "traverse": "0.6.8" } }, "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ=="], @@ -799,7 +804,7 @@ "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], - "is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -927,7 +932,7 @@ "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], - "meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], + "meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], @@ -1019,7 +1024,7 @@ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + "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=="], "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], @@ -1075,7 +1080,7 @@ "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], - "read-package-up": ["read-package-up@12.0.0", "", { "dependencies": { "find-up-simple": "^1.0.1", "read-pkg": "^10.0.0", "type-fest": "^5.2.0" } }, "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw=="], + "read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="], "read-pkg": ["read-pkg@10.0.0", "", { "dependencies": { "@types/normalize-package-data": "^2.4.4", "normalize-package-data": "^8.0.0", "parse-json": "^8.3.0", "type-fest": "^5.2.0", "unicorn-magic": "^0.3.0" } }, "sha512-A70UlgfNdKI5NSvTTfHzLQj7NJRpJ4mT5tGafkllJ4wh71oYuGm/pzphHcmW4s35iox56KSK721AihodoXSc/A=="], @@ -1309,6 +1314,8 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@commitlint/config-conventional/conventional-changelog-conventionalcommits": ["conventional-changelog-conventionalcommits@7.0.2", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w=="], + "@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=="], @@ -1345,10 +1352,6 @@ "@semantic-release/npm/execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], - "@semantic-release/release-notes-generator/get-stream": ["get-stream@7.0.1", "", {}, "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ=="], - - "@semantic-release/release-notes-generator/read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], @@ -1377,9 +1380,7 @@ "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=="], - "conventional-changelog-writer/meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], - - "conventional-commits-parser/meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], + "cosmiconfig/parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], "crypto-random-string/type-fest": ["type-fest@1.4.0", "", {}, "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA=="], @@ -1389,6 +1390,8 @@ "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "figures/is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], @@ -1399,6 +1402,8 @@ "from2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "git-raw-commits/meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], + "git-raw-commits/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], @@ -1741,6 +1746,8 @@ "p-filter/p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], + "parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -1749,9 +1756,9 @@ "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-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=="], - "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-package-up/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "read-pkg/type-fest": ["type-fest@5.2.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA=="], @@ -1759,12 +1766,18 @@ "semantic-release/@semantic-release/error": ["@semantic-release/error@4.0.0", "", {}, "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ=="], + "semantic-release/@semantic-release/npm": ["@semantic-release/npm@13.1.2", "", { "dependencies": { "@actions/core": "^1.11.1", "@semantic-release/error": "^4.0.0", "aggregate-error": "^5.0.0", "env-ci": "^11.2.0", "execa": "^9.0.0", "fs-extra": "^11.0.0", "lodash-es": "^4.17.21", "nerf-dart": "^1.0.0", "normalize-url": "^8.0.0", "npm": "^11.6.2", "rc": "^1.2.8", "read-pkg": "^10.0.0", "registry-auth-token": "^5.0.0", "semver": "^7.1.2", "tempy": "^3.0.0" }, "peerDependencies": { "semantic-release": ">=20.1.0" } }, "sha512-9rtshDTNlzYrC7uSBtB1vHqFzFZaNHigqkkCH5Ls4N/BSlVOenN5vtwHYxjAR4jf1hNvWSVwL4eIFTHONYckkw=="], + "semantic-release/aggregate-error": ["aggregate-error@5.0.0", "", { "dependencies": { "clean-stack": "^5.2.0", "indent-string": "^5.0.0" } }, "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw=="], "semantic-release/execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "semantic-release/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + "semantic-release/p-reduce": ["p-reduce@3.0.0", "", {}, "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q=="], + "semantic-release/read-package-up": ["read-package-up@12.0.0", "", { "dependencies": { "find-up-simple": "^1.0.1", "read-pkg": "^10.0.0", "type-fest": "^5.2.0" } }, "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw=="], + "semantic-release/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -1797,8 +1810,12 @@ "through2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "yargs-unparser/is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="], + "@commitlint/config-validator/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@commitlint/parse/conventional-commits-parser/meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], + "@commitlint/parse/conventional-commits-parser/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "@commitlint/top-level/find-up/locate-path": ["locate-path@7.2.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA=="], @@ -1823,8 +1840,6 @@ "@semantic-release/npm/execa/human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], - "@semantic-release/npm/execa/is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "@semantic-release/npm/execa/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "@semantic-release/npm/execa/npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], @@ -1833,10 +1848,6 @@ "@semantic-release/npm/execa/strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], - "@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=="], - "@types/chokidar/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], @@ -1893,7 +1904,11 @@ "pkg-conf/find-up/locate-path": ["locate-path@2.0.0", "", { "dependencies": { "p-locate": "^2.0.0", "path-exists": "^3.0.0" } }, "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA=="], - "read-pkg/parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "read-package-up/read-pkg/normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], + + "read-package-up/read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], + + "semantic-release/@semantic-release/npm/@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="], "semantic-release/aggregate-error/clean-stack": ["clean-stack@5.3.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg=="], @@ -1903,8 +1918,6 @@ "semantic-release/execa/human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], - "semantic-release/execa/is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "semantic-release/execa/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "semantic-release/execa/npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], @@ -1913,6 +1926,8 @@ "semantic-release/execa/strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + "semantic-release/read-package-up/type-fest": ["type-fest@5.2.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA=="], + "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=="], @@ -1951,12 +1966,6 @@ "@semantic-release/npm/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - "@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], - - "@semantic-release/release-notes-generator/read-package-up/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=="], - - "@semantic-release/release-notes-generator/read-package-up/read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], - "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=="], @@ -1971,6 +1980,12 @@ "pkg-conf/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + "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=="], + + "semantic-release/@semantic-release/npm/@actions/core/@actions/exec": ["@actions/exec@1.1.1", "", { "dependencies": { "@actions/io": "^1.0.1" } }, "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w=="], + + "semantic-release/@semantic-release/npm/@actions/core/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + "semantic-release/aggregate-error/clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "semantic-release/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], @@ -1991,16 +2006,18 @@ "@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=="], - "pkg-conf/find-up/locate-path/p-locate/p-limit": ["p-limit@1.3.0", "", { "dependencies": { "p-try": "^1.0.0" } }, "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q=="], + "read-package-up/read-pkg/normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "semantic-release/@semantic-release/npm/@actions/core/@actions/exec/@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="], + + "semantic-release/@semantic-release/npm/@actions/core/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "semantic-release/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "signale/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "@commitlint/top-level/find-up/locate-path/p-locate/p-limit/yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], - - "@semantic-release/release-notes-generator/read-package-up/read-pkg/normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], } } diff --git a/installers/install.ps1 b/installers/install.ps1 deleted file mode 100644 index 27a4a0f1..00000000 --- a/installers/install.ps1 +++ /dev/null @@ -1,808 +0,0 @@ -# CCS Installation Script (v4.5.0) - Windows PowerShell - DEPRECATED -# DEPRECATED: This installer is deprecated. Use npm instead. -# Bootstrap-based: Installs lightweight shell wrappers (LEGACY) -# Requires: Node.js 14+ (npm recommended) -# https://github.com/kaitranntt/ccs - -param( - [string]$InstallDir = "$env:USERPROFILE\.ccs" -) - -$ErrorActionPreference = "Stop" - -# --- Deprecation Notice --- -Write-Host "" -Write-Host "=======================================================================" -ForegroundColor Yellow -Write-Host " " -ForegroundColor Yellow -Write-Host " [!] DEPRECATION NOTICE " -ForegroundColor Yellow -Write-Host " " -ForegroundColor Yellow -Write-Host " Native shell installers are deprecated and will be removed " -ForegroundColor Yellow -Write-Host " in a future version. Please use npm installation instead: " -ForegroundColor Yellow -Write-Host " " -ForegroundColor Yellow -Write-Host " npm install -g @kaitranntt/ccs " -ForegroundColor Yellow -Write-Host " " -ForegroundColor Yellow -Write-Host " Proceeding with legacy install (auto-runs npm if available)... " -ForegroundColor Yellow -Write-Host " " -ForegroundColor Yellow -Write-Host "=======================================================================" -ForegroundColor Yellow -Write-Host "" -Start-Sleep -Seconds 3 - -# --- Auto-redirect to npm installation --- -if (Get-Command npm -ErrorAction SilentlyContinue) { - Write-Host "[i] Node.js detected, using npm installation (recommended)..." -ForegroundColor Cyan - Write-Host "" - - npm install -g "@kaitranntt/ccs" - - if ($LASTEXITCODE -eq 0) { - Write-Host "" - Write-Host "[OK] CCS installed via npm successfully!" -ForegroundColor Green - Write-Host "" - Write-Host "Quick start:" - Write-Host " ccs # Use Claude (default)" - Write-Host " ccs glm # Use GLM" - Write-Host " ccs --help # Show all commands" - Write-Host "" - exit 0 - } else { - Write-Host "" - Write-Host "[!] npm installation failed. Falling back to legacy install..." -ForegroundColor Yellow - Write-Host "" - Start-Sleep -Seconds 2 - } -} else { - Write-Host "[!] npm not found. Falling back to legacy install..." -ForegroundColor Yellow - Write-Host "[!] Install Node.js from https://nodejs.org for the recommended method." -ForegroundColor Yellow - Write-Host "" - Start-Sleep -Seconds 2 -} - -# Continue with legacy PowerShell installation... - -# Configuration -$CcsDir = "$env:USERPROFILE\.ccs" -$ClaudeDir = "$env:USERPROFILE\.claude" -$GlmModel = "glm-4.6" -$KimiModel = "kimi-for-coding" - -# Detect if running from git repository or standalone -$ScriptDir = if ($MyInvocation.MyCommand.Path) { - Split-Path -Parent $MyInvocation.MyCommand.Path -} else { - # Running via irm | iex (in-memory, no file path) - $null -} - -$InstallMethod = if ($ScriptDir -and ((Test-Path "$ScriptDir\lib\ccs.ps1") -or (Test-Path "$ScriptDir\..\lib\ccs.ps1"))) { - "git" -} else { - "standalone" -} - -# Version configuration -# 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 = "6.5.0" - -# Try to read VERSION file for git installations -if ($ScriptDir) { - $VersionFile = if (Test-Path "$ScriptDir\VERSION") { - "$ScriptDir\VERSION" - } elseif (Test-Path "$ScriptDir\..\VERSION") { - "$ScriptDir\..\VERSION" - } else { - $null - } - - if ($VersionFile -and (Test-Path $VersionFile)) { - $CcsVersion = (Get-Content $VersionFile -Raw).Trim() - } -} - -# --- Color/Format Functions --- -function Write-Critical { - param([string]$Message) - Write-Host "" - Write-Host "╔═════════════════════════════════════════════╗" -ForegroundColor Red - Write-Host "║ ACTION REQUIRED ║" -ForegroundColor Red - Write-Host "╚═════════════════════════════════════════════╝" -ForegroundColor Red - Write-Host "" - Write-Host $Message -ForegroundColor Red - Write-Host "" -} - -function Write-WarningMsg { - param([string]$Message) - Write-Host "" - Write-Host "[!] WARNING" -ForegroundColor Yellow - Write-Host $Message -ForegroundColor Yellow - Write-Host "" -} - -function Write-Success { - param([string]$Message) - Write-Host "[OK] $Message" -ForegroundColor Green -} - -function Write-Info { - param([string]$Message) - Write-Host "[i] $Message" -} - -function Write-Section { - param([string]$Title) - Write-Host "" - Write-Host "===== $Title =====" -ForegroundColor Cyan - Write-Host "" -} - -# --- Node.js Detection (v4.5) --- -function Test-NodeJs { - $MIN_VERSION = 14 - - if (-not (Get-Command node -ErrorAction SilentlyContinue)) { - Write-WarningMsg @" -Node.js not found - -CCS v4.5+ requires Node.js 14+ to run. -The bootstrap scripts will check and install the npm package on first use. - -Install Node.js: https://nodejs.org (LTS recommended) - -Installation will continue, but 'ccs' will not work until Node.js is installed. -"@ - return $false - } - - $nodeVersion = (node -v) -replace 'v', '' - $nodeMajor = [int]($nodeVersion -split '\.')[0] - if ($nodeMajor -lt $MIN_VERSION) { - Write-WarningMsg @" -Node.js 14+ required (found: $(node -v)) - -CCS v4.5+ requires Node.js 14 or newer. -Upgrade from: https://nodejs.org - -Installation will continue, but 'ccs' may not work correctly. -"@ - return $false - } - - Write-Success "Node.js $(node -v) detected" - return $true -} - -# Helper Functions - -function Detect-CurrentProvider { - $SettingsFile = "$ClaudeDir\settings.json" - if (-not (Test-Path $SettingsFile)) { - return "unknown" - } - - try { - $Content = Get-Content $SettingsFile -Raw - if ($Content -match "api\.kimi\.com|kimi-for-coding") { - return "kimi" - } elseif ($Content -match "api\.z\.ai|glm-4") { - return "glm" - } elseif ($Content -match "ANTHROPIC_BASE_URL" -and $Content -notmatch "api\.z\.ai|api\.kimi\.com") { - return "custom" - } else { - return "claude" - } - } catch { - return "unknown" - } -} - -function New-GlmTemplate { - $Template = @{ - env = @{ - ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic" - ANTHROPIC_AUTH_TOKEN = "YOUR_GLM_API_KEY_HERE" - ANTHROPIC_MODEL = $GlmModel - ANTHROPIC_DEFAULT_OPUS_MODEL = $GlmModel - ANTHROPIC_DEFAULT_SONNET_MODEL = $GlmModel - ANTHROPIC_DEFAULT_HAIKU_MODEL = $GlmModel - } - } - return $Template | ConvertTo-Json -Depth 10 -} - -function New-GlmProfile { - param([string]$Provider) - - $CurrentSettings = "$ClaudeDir\settings.json" - $GlmSettings = "$CcsDir\glm.settings.json" - - if ($Provider -eq "glm" -and (Test-Path $CurrentSettings)) { - Write-Host "[OK] Copying current GLM config to profile..." - - try { - $Config = Get-Content $CurrentSettings -Raw | ConvertFrom-Json - if (-not $Config.env) { - $Config | Add-Member -NotePropertyName env -NotePropertyValue @{} -Force - } - $Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_OPUS_MODEL -NotePropertyValue $GlmModel -Force - $Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_SONNET_MODEL -NotePropertyValue $GlmModel -Force - $Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_HAIKU_MODEL -NotePropertyValue $GlmModel -Force - - $Config | ConvertTo-Json -Depth 10 | Set-Content $GlmSettings - Write-Host " Created: $GlmSettings with your existing API key + enhanced settings" - } catch { - Write-Host " [i] Copying current settings failed, using template" - New-GlmTemplate | Set-Content $GlmSettings - } - } else { - Write-Host "Creating GLM profile template at $GlmSettings" - New-GlmTemplate | Set-Content $GlmSettings - Write-Host " Created: $GlmSettings" - Write-Host " [!] Edit this file and replace YOUR_GLM_API_KEY_HERE with your actual GLM API key" - } -} - -function New-KimiTemplate { - $Template = @{ - env = @{ - ANTHROPIC_BASE_URL = "https://api.kimi.com/coding/" - ANTHROPIC_AUTH_TOKEN = "YOUR_KIMI_API_KEY_HERE" - ANTHROPIC_MODEL = $KimiModel - ANTHROPIC_SMALL_FAST_MODEL = $KimiModel - ANTHROPIC_DEFAULT_OPUS_MODEL = $KimiModel - ANTHROPIC_DEFAULT_SONNET_MODEL = $KimiModel - ANTHROPIC_DEFAULT_HAIKU_MODEL = $KimiModel - } - alwaysThinkingEnabled = $true - } - return $Template | ConvertTo-Json -Depth 10 -} - -function New-KimiProfile { - param([string]$Provider) - - $CurrentSettings = "$ClaudeDir\settings.json" - $KimiSettings = "$CcsDir\kimi.settings.json" - - if ($Provider -eq "kimi" -and (Test-Path $CurrentSettings)) { - Write-Host "[OK] Copying current Kimi config to profile..." - - try { - $Config = Get-Content $CurrentSettings -Raw | ConvertFrom-Json - if (-not $Config.env) { - $Config | Add-Member -NotePropertyName env -NotePropertyValue @{} -Force - } - $Config.env | Add-Member -NotePropertyName ANTHROPIC_SMALL_FAST_MODEL -NotePropertyValue $KimiModel -Force - $Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_OPUS_MODEL -NotePropertyValue $KimiModel -Force - $Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_SONNET_MODEL -NotePropertyValue $KimiModel -Force - $Config.env | Add-Member -NotePropertyName ANTHROPIC_DEFAULT_HAIKU_MODEL -NotePropertyValue $KimiModel -Force - $Config | Add-Member -NotePropertyName alwaysThinkingEnabled -NotePropertyValue $true -Force - - $Config | ConvertTo-Json -Depth 10 | Set-Content $KimiSettings - Write-Host " Created: $KimiSettings with your existing API key + enhanced settings" - } catch { - Write-Host " [i] Copying current settings failed, using template" - New-KimiTemplate | Set-Content $KimiSettings - } - } else { - Write-Host "Creating Kimi profile template at $KimiSettings" - New-KimiTemplate | Set-Content $KimiSettings - Write-Host " Created: $KimiSettings" - Write-Host " [!] Edit this file and replace YOUR_KIMI_API_KEY_HERE with your actual Kimi API key" - } -} - -function Install-ClaudeFolder { - param( - [string]$SourceDir - ) - - $TargetDir = "$CcsDir\.claude" - - # Check if already exists - if (Test-Path $TargetDir) { - Write-Host "| [i] .claude/ folder already exists, skipping" - return $true - } - - # Create directory structure - $null = New-Item -ItemType Directory -Force -Path "$TargetDir\commands" - $null = New-Item -ItemType Directory -Force -Path "$TargetDir\skills\ccs-delegation\references" - - if ($InstallMethod -eq "git" -and $SourceDir) { - # Copy from local git repo - $SourceClaudeDir = Join-Path $SourceDir ".claude" - if (Test-Path $SourceClaudeDir) { - try { - Copy-Item -Path "$SourceClaudeDir\*" -Destination $TargetDir -Recurse -Force - Write-Host "| [OK] Installed .claude/ folder" - return $true - } catch { - Write-Host "| [!] Failed to copy .claude/ folder" - return $false - } - } else { - Write-Host "| [!] .claude/ folder not found in source" - return $false - } - } else { - # Standalone: download from GitHub - try { - $BaseUrl = "https://raw.githubusercontent.com/kaitranntt/ccs/main/.claude" - - Invoke-WebRequest -Uri "$BaseUrl/commands/ccs.md" ` - -OutFile "$TargetDir\commands\ccs.md" -UseBasicParsing - Invoke-WebRequest -Uri "$BaseUrl/skills/ccs-delegation/SKILL.md" ` - -OutFile "$TargetDir\skills\ccs-delegation\SKILL.md" -UseBasicParsing - Invoke-WebRequest -Uri "$BaseUrl/skills/ccs-delegation/references/delegation-patterns.md" ` - -OutFile "$TargetDir\skills\ccs-delegation\references\delegation-patterns.md" -UseBasicParsing - - Write-Host "| [OK] Downloaded .claude/ folder" - return $true - } catch { - Write-Host "| [!] Failed to download .claude/ folder" - return $false - } - } -} - -# Main Installation - -# Check Node.js requirement (warn if missing, continue anyway) -$null = Test-NodeJs - -Write-Host '===== Installing CCS (Windows) =====' - -# Create directories -New-Item -ItemType Directory -Force -Path $CcsDir | Out-Null - -# Install main executable -if ($InstallMethod -eq "standalone") { - # Standalone install - download from GitHub - Write-Host "| Downloading CCS from GitHub..." - - try { - $BaseUrl = "https://raw.githubusercontent.com/kaitranntt/ccs/main" - Invoke-WebRequest -Uri "$BaseUrl/lib/ccs.ps1" -OutFile "$CcsDir\ccs.ps1" -UseBasicParsing - Write-Host "| [OK] Downloaded ccs.ps1" - - # Note: Shell dependencies (error-codes.ps1, progress-indicator.ps1, prompt.ps1) no longer needed - # Bootstrap delegates all functionality to Node.js via npx - - # Download shell completion files - $CompletionsDir = "$CcsDir\completions" - if (-not (Test-Path $CompletionsDir)) { - New-Item -ItemType Directory -Path $CompletionsDir -Force | Out-Null - } - - try { - Invoke-WebRequest -Uri "$BaseUrl/scripts/completion/ccs.ps1" -OutFile "$CompletionsDir\ccs.ps1" -UseBasicParsing - Write-Host "| [OK] Downloaded completion files" - } catch { - Write-Host "| [!] Warning: Failed to download completion files" - } - } catch { - Write-Host "|" - Write-Host "[X] Error: Failed to download ccs.ps1 from GitHub" -ForegroundColor Red - Write-Host " $_" - return - } -} else { - # Git install - copy local file - $CcsPs1Path = if (Test-Path "$ScriptDir\lib\ccs.ps1") { - "$ScriptDir\lib\ccs.ps1" - } elseif (Test-Path "$ScriptDir\..\lib\ccs.ps1") { - "$ScriptDir\..\lib\ccs.ps1" - } else { - throw "lib\ccs.ps1 not found" - } - Copy-Item $CcsPs1Path "$CcsDir\ccs.ps1" -Force - Write-Host "| [OK] Installed ccs.ps1" - - # Note: Shell dependencies (error-codes.ps1, progress-indicator.ps1, prompt.ps1) no longer needed - # Bootstrap delegates all functionality to Node.js via npx - - # Copy shell completion files - $CompletionsDir = "$CcsDir\completions" - if (-not (Test-Path $CompletionsDir)) { - New-Item -ItemType Directory -Path $CompletionsDir -Force | Out-Null - } - - $SourceCompletionDir = if (Test-Path "$ScriptDir\scripts\completion") { - "$ScriptDir\scripts\completion" - } elseif (Test-Path "$ScriptDir\..\scripts\completion") { - "$ScriptDir\..\scripts\completion" - } else { - $null - } - - if ($SourceCompletionDir -and (Test-Path "$SourceCompletionDir\ccs.ps1")) { - Copy-Item "$SourceCompletionDir\ccs.ps1" "$CompletionsDir\ccs.ps1" -Force -ErrorAction SilentlyContinue - Write-Host "| [OK] Copied completion files" - } -} - -# Install uninstall script as ccs-uninstall.ps1 -if ($ScriptDir -and (Test-Path "$ScriptDir\uninstall.ps1")) { - # Copy uninstall.ps1 as ccs-uninstall.ps1 (similar to Linux symlink approach) - if ($ScriptDir -ne $CcsDir) { - Copy-Item "$ScriptDir\uninstall.ps1" "$CcsDir\ccs-uninstall.ps1" -Force - } - # Clean up old uninstall.ps1 from previous installations - if (Test-Path "$CcsDir\uninstall.ps1") { - Remove-Item "$CcsDir\uninstall.ps1" -Force -ErrorAction SilentlyContinue - } - Write-Host "| [OK] Installed uninstaller" -} elseif ($InstallMethod -eq "standalone") { - try { - $BaseUrl = "https://raw.githubusercontent.com/kaitranntt/ccs/main" - # Download uninstall.ps1 as ccs-uninstall.ps1 - Invoke-WebRequest -Uri "$BaseUrl/installers/uninstall.ps1" -OutFile "$CcsDir\ccs-uninstall.ps1" -UseBasicParsing - # Clean up old uninstall.ps1 from previous installations - if (Test-Path "$CcsDir\uninstall.ps1") { - Remove-Item "$CcsDir\uninstall.ps1" -Force -ErrorAction SilentlyContinue - } - Write-Host "| [OK] Installed uninstaller" - } catch { - Write-Host "| [!] Could not download uninstaller (optional)" - } -} - -Write-Host "| [OK] Created directories" - -# Install .claude/ folder -if ($InstallMethod -eq "git" -and $ScriptDir) { - $ParentDir = Split-Path -Parent $ScriptDir - $null = Install-ClaudeFolder -SourceDir $ParentDir -} else { - $null = Install-ClaudeFolder -SourceDir "" -} - -Write-Host "=========================================" -Write-Host "" - -# Profile Setup - -$CurrentProvider = Detect-CurrentProvider - -$ProviderLabel = switch ($CurrentProvider) { - "glm" { ' (detected: GLM)' } - "kimi" { ' (detected: Kimi)' } - "claude" { ' (detected: Claude)' } - "custom" { ' (detected: custom)' } - default { "" } -} - -Write-Host "===== Configuring Profiles v$CcsVersion$ProviderLabel" - -# Backup existing config (single backup, no timestamp) -$ConfigFile = "$CcsDir\config.json" -$BackupFile = "$CcsDir\config.json.backup" -if (Test-Path $ConfigFile) { - Copy-Item $ConfigFile $BackupFile -Force -} - -$NeedsGlmKey = $false -$GlmSettings = "$CcsDir\glm.settings.json" - -# Create GLM profile if missing -if (-not (Test-Path $GlmSettings)) { - New-GlmProfile -Provider $CurrentProvider - if ($CurrentProvider -ne "glm") { - $NeedsGlmKey = $true - } -} else { - Write-Host '| [OK] GLM profile exists' -} - -$NeedsKimiKey = $false -$KimiSettings = "$CcsDir\kimi.settings.json" - -# Create Kimi profile if missing -if (-not (Test-Path $KimiSettings)) { - New-KimiProfile -Provider $CurrentProvider - if ($CurrentProvider -ne "kimi") { - $NeedsKimiKey = $true - } -} else { - Write-Host '| [OK] Kimi profile exists' -} - -# Create config if missing -if (-not (Test-Path $ConfigFile)) { - $ConfigContent = @{ - profiles = @{ - glm = "~/.ccs/glm.settings.json" - kimi = "~/.ccs/kimi.settings.json" - default = "~/.claude/settings.json" - } - } - $ConfigContent | ConvertTo-Json -Depth 10 | Set-Content $ConfigFile - Write-Host ('| OK: Config created at {0}\.ccs\config.json' -f $env:USERPROFILE) -} - -# Validate config JSON -if (Test-Path $ConfigFile) { - try { - $null = Get-Content $ConfigFile -Raw | ConvertFrom-Json - } catch { - Write-Host '| [!] Warning: Invalid JSON in config.json' -ForegroundColor Yellow - if (Test-Path $BackupFile) { - Write-Host ('| Restore from: {0}' -f $BackupFile) - } - } -} - -# Validate GLM settings JSON -if (Test-Path $GlmSettings) { - try { - $null = Get-Content $GlmSettings -Raw | ConvertFrom-Json - } catch { - Write-Host '| [!] Warning: Invalid JSON in glm.settings.json' -ForegroundColor Yellow - } -} - -Write-Host "=========================================" -Write-Host "" - -# Detect circular symlink -function Test-CircularSymlink { - param( - [string]$Target, - [string]$LinkPath - ) - - # Check if target exists and is symlink - if (-not (Test-Path $Target)) { - return $false - } - - try { - $Item = Get-Item $Target -ErrorAction Stop - if ($Item.LinkType -ne "SymbolicLink") { - return $false - } - - # Resolve target's link - $TargetLink = $Item.Target - $SharedDir = "$env:USERPROFILE\.ccs\shared" - - # Check if target points back to our shared dir - if ($TargetLink -like "$SharedDir*" -or $TargetLink -eq $LinkPath) { - Write-Host "[!] Circular symlink detected: $Target → $TargetLink" - return $true - } - } catch { - return $false - } - - return $false -} - -# Setup shared directories as symlinks to ~/.claude/ (v3.2.0) -function Initialize-SharedSymlinks { - $SharedDir = "$CcsDir\shared" - $ClaudeDir = "$env:USERPROFILE\.claude" - - # Create ~/.claude/ if missing - if (-not (Test-Path $ClaudeDir)) { - Write-Host "[i] Creating ~/.claude/ directory structure" - New-Item -ItemType Directory -Path $ClaudeDir -Force | Out-Null - @('commands', 'skills', 'agents') | ForEach-Object { - New-Item -ItemType Directory -Path "$ClaudeDir\$_" -Force | Out-Null - } - } - - # Create shared directory - if (-not (Test-Path $SharedDir)) { - New-Item -ItemType Directory -Path $SharedDir -Force | Out-Null - } - - # Create symlinks ~/.ccs/shared/* → ~/.claude/* - foreach ($Dir in @('commands', 'skills', 'agents')) { - $ClaudePath = "$ClaudeDir\$Dir" - $SharedPath = "$SharedDir\$Dir" - - # Create directory in ~/.claude/ if missing - if (-not (Test-Path $ClaudePath)) { - New-Item -ItemType Directory -Path $ClaudePath -Force | Out-Null - } - - # Check for circular symlink - if (Test-CircularSymlink -Target $ClaudePath -LinkPath $SharedPath) { - Write-Host "[!] Skipping $Dir`: circular symlink detected" - continue - } - - # If already correct symlink, skip - if (Test-Path $SharedPath) { - try { - $Item = Get-Item $SharedPath -ErrorAction Stop - if ($Item.LinkType -eq "SymbolicLink") { - $CurrentTarget = $Item.Target - if ($CurrentTarget -eq $ClaudePath) { - continue # Already correct - } - } - # Backup existing data before replacing - if ((Get-ChildItem $SharedPath -ErrorAction SilentlyContinue).Count -gt 0) { - Write-Host "[i] Migrating existing $Dir to ~/.claude/$Dir" - Get-ChildItem $SharedPath -ErrorAction SilentlyContinue | ForEach-Object { - $DestPath = Join-Path $ClaudePath $_.Name - if (-not (Test-Path $DestPath)) { - Copy-Item $_.FullName $DestPath -Recurse -ErrorAction SilentlyContinue - } - } - } - Remove-Item $SharedPath -Recurse -Force -ErrorAction SilentlyContinue - } catch { - # Continue to recreate - } - } - - # Create symlink (requires Developer Mode or admin) - try { - New-Item -ItemType SymbolicLink -Path $SharedPath -Target $ClaudePath -Force -ErrorAction Stop | Out-Null - } catch { - Write-Host "[!] Symlink failed for $Dir, copying instead (enable Developer Mode)" - if (-not (Test-Path $SharedPath)) { - New-Item -ItemType Directory -Path $SharedPath -Force | Out-Null - } - if (Test-Path $ClaudePath) { - Copy-Item "$ClaudePath\*" $SharedPath -Recurse -ErrorAction SilentlyContinue - } - } - } -} - -Write-Host "[i] Setting up shared directories..." -Initialize-SharedSymlinks -Write-Host "" - -# Install CCS items to ~/.claude/ via symlinks (v4.1.0) -Write-Host "[i] Installing CCS items to ~/.claude/..." -if (Get-Command node -ErrorAction SilentlyContinue) { - # Check if .claude/ was successfully installed - if (Test-Path "$CcsDir\.claude") { - # Download or copy claude-symlink-manager.js - $UtilsDir = "$CcsDir\bin\utils" - if (-not (Test-Path $UtilsDir)) { - New-Item -ItemType Directory -Path $UtilsDir -Force | Out-Null - } - - if ($InstallMethod -eq "git" -and $ScriptDir) { - # Git install - copy from local repo - $SourcePath = $null - if (Test-Path "$ScriptDir\..\bin\utils\claude-symlink-manager.js") { - $SourcePath = "$ScriptDir\..\bin\utils\claude-symlink-manager.js" - } elseif (Test-Path "$ScriptDir\bin\utils\claude-symlink-manager.js") { - $SourcePath = "$ScriptDir\bin\utils\claude-symlink-manager.js" - } - - if ($SourcePath) { - Copy-Item $SourcePath "$UtilsDir\claude-symlink-manager.js" -Force - } - } else { - # Standalone install - download from GitHub - try { - Invoke-WebRequest -Uri "https://raw.githubusercontent.com/kaitranntt/ccs/main/bin/utils/claude-symlink-manager.js" ` - -OutFile "$UtilsDir\claude-symlink-manager.js" -UseBasicParsing - } catch { - Write-Host "[!] Failed to download claude-symlink-manager.js" - } - } - - # Call ClaudeSymlinkManager if available - if (Test-Path "$UtilsDir\claude-symlink-manager.js") { - try { - $scriptBlock = @" - try { - const ClaudeSymlinkManager = require('$($UtilsDir -replace '\\', '/')/claude-symlink-manager.js'); - const manager = new ClaudeSymlinkManager(); - manager.install(); - } catch (err) { - console.log('[!] CCS item installation warning: ' + err.message); - console.log(' Run "ccs sync" to retry'); - } -"@ - node -e $scriptBlock 2>$null - if (-not $?) { - Write-Host "[!] CCS item installation skipped (run 'ccs sync' later)" - } - } catch { - Write-Host "[!] CCS item installation failed: $($_.Exception.Message)" - Write-Host " Run 'ccs sync' after installation to complete setup" - } - } else { - Write-Host "[!] claude-symlink-manager.js not found, skipping" - Write-Host " Run 'ccs sync' after installation to complete setup" - } - } else { - Write-Host "[!] .claude/ folder not found, skipping CCS item installation" - } -} else { - Write-Host "[!] Node.js not found, skipping CCS item installation" - Write-Host " Install Node.js and run 'ccs sync' to complete setup" -} -Write-Host "" -Write-Host "[i] Note: Windows symlink support requires Developer Mode (v4.2 will add fallback)" -Write-Host "" - -# Check and update PATH -$UserPath = [Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::User) -if ($UserPath -notlike "*$CcsDir*") { - Write-Host "[!] PATH Configuration Required" - Write-Host "" - Write-Host " Adding $CcsDir to your PATH..." - - try { - $NewPath = if ($UserPath) { "$UserPath;$CcsDir" } else { $CcsDir } - [Environment]::SetEnvironmentVariable("Path", $NewPath, [System.EnvironmentVariableTarget]::User) - - Write-Host " [OK] PATH updated. Restart your terminal for changes to take effect." - Write-Host "" - } catch { - Write-Host " [X] Could not update PATH automatically." -ForegroundColor Yellow - Write-Host " Please add manually: $CcsDir" - Write-Host "" - } -} - -# Show API key warning if needed -if ($NeedsGlmKey) { - Write-Critical @" -Configure GLM API Key: - - 1. Get API key from: https://api.z.ai - - 2. Edit: $env:USERPROFILE\.ccs\glm.settings.json - - 3. Replace: YOUR_GLM_API_KEY_HERE - With your actual API key - - 4. Test: ccs glm --version -"@ -} - -# Show API key warning for Kimi if needed -if ($NeedsKimiKey) { - Write-Critical @" -Configure Kimi API Key: - - 1. Get API key from: https://www.kimi.com/coding - - 2. Edit: $env:USERPROFILE\.ccs\kimi.settings.json - - 3. Replace: YOUR_KIMI_API_KEY_HERE - With your actual API key - - 4. Test: ccs kimi --version -"@ -} - -Write-Success "CCS installed successfully!" -Write-Host "" -Write-Host " Installed components:" -Write-Host " * ccs command -> $CcsDir\ccs.ps1" -Write-Host " * config -> $CcsDir\config.json" -Write-Host " * glm profile -> $CcsDir\glm.settings.json" -Write-Host " * kimi profile -> $CcsDir\kimi.settings.json" -Write-Host " * .claude/ folder -> $CcsDir\.claude\" -Write-Host "" -Write-Host " Requirements:" -$nodeVer = if (Get-Command node -ErrorAction SilentlyContinue) { node -v } else { "NOT FOUND" } -Write-Host " * Node.js 14+ (detected: $nodeVer)" -Write-Host " * npm 5.2+ (for npx, comes with Node.js 8.2+)" -Write-Host "" -Write-Host " First Run:" -Write-Host " The first time you run 'ccs', it will automatically install" -Write-Host " the @kaitranntt/ccs npm package globally via npx." -Write-Host "" -Write-Host " Quick start:" -Write-Host " ccs # Use Claude subscription (default)" -Write-Host " ccs glm # Use GLM fallback" -Write-Host " ccs kimi # Use Kimi for Coding" -Write-Host "" -Write-Host " To uninstall: ccs-uninstall" -Write-Host "" diff --git a/installers/install.sh b/installers/install.sh deleted file mode 100755 index 0b56b411..00000000 --- a/installers/install.sh +++ /dev/null @@ -1,927 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# ============================================================================ -# CCS Installation Script (v4.5.0) - DEPRECATED -# DEPRECATED: This installer is deprecated. Use npm instead. -# Bootstrap-based: Installs lightweight shell wrappers (LEGACY) -# Requires: Node.js 14+ (npm recommended) -# ============================================================================ - -# --- Deprecation Notice --- -echo "" -echo "=======================================================================" -echo " " -echo " [!] DEPRECATION NOTICE " -echo " " -echo " Native shell installers are deprecated and will be removed " -echo " in a future version. Please use npm installation instead: " -echo " " -echo " npm install -g @kaitranntt/ccs " -echo " " -echo " Proceeding with legacy install (auto-runs npm if available)... " -echo " " -echo "=======================================================================" -echo "" -sleep 3 # Give users time to read - -# --- Auto-redirect to npm installation --- -if command -v npm &> /dev/null; then - echo "[i] Node.js detected, using npm installation (recommended)..." - echo "" - npm install -g @kaitranntt/ccs - exit_code=$? - - if [ $exit_code -eq 0 ]; then - echo "" - echo "[OK] CCS installed via npm successfully!" - echo "" - echo "Quick start:" - echo " ccs # Use Claude (default)" - echo " ccs glm # Use GLM" - echo " ccs --help # Show all commands" - echo "" - exit 0 - else - echo "" - echo "[!] npm installation failed. Falling back to legacy install..." - echo "" - sleep 2 - fi -else - echo "[!] npm not found. Falling back to legacy install..." - echo "[!] Install Node.js from https://nodejs.org for the recommended method." - echo "" - sleep 2 -fi - -# Continue with legacy bash installation... - -# --- Configuration --- -INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" -CCS_DIR="$HOME/.ccs" -CLAUDE_DIR="$HOME/.claude" -GLM_MODEL="glm-4.6" -KIMI_MODEL="kimi-k2-thinking-turbo" - -# Resolve script directory (handles both file-based and piped execution) -if [[ -n "${BASH_SOURCE[0]:-}" ]]; then - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -else - SCRIPT_DIR="$(cd "$(dirname "${0:-$PWD}")" && pwd)" -fi - -# Detect installation method (git vs standalone) -# Check if ccs executable exists in SCRIPT_DIR or parent (real git install) -# Don't just check .git (user might run curl | bash inside their own git repo) -if [[ -f "$SCRIPT_DIR/lib/ccs" ]] || [[ -f "$SCRIPT_DIR/../lib/ccs" ]]; then - INSTALL_METHOD="git" -else - INSTALL_METHOD="standalone" -fi - -# Version configuration -# 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="6.5.0" - -# Try to read VERSION file for git installations -if [[ -f "$SCRIPT_DIR/VERSION" ]]; then - CCS_VERSION="$(cat "$SCRIPT_DIR/VERSION" | tr -d '\n' | tr -d '\r')" -elif [[ -f "$SCRIPT_DIR/../VERSION" ]]; then - CCS_VERSION="$(cat "$SCRIPT_DIR/../VERSION" | tr -d '\n' | tr -d '\r')" -fi - -# --- Platform Detection --- -# Detect platform and redirect to Windows installer if needed -detect_platform() { - case "$OSTYPE" in - msys*|mingw*|cygwin*|win32*) - echo "windows" - ;; - *) - echo "unix" - ;; - esac -} - -PLATFORM=$(detect_platform) - -if [[ "$PLATFORM" == "windows" ]]; then - echo "Windows detected. Using PowerShell installer..." - - if [[ -f "$SCRIPT_DIR/install.ps1" ]]; then - powershell.exe -ExecutionPolicy Bypass -File "$SCRIPT_DIR/install.ps1" - exit $? - else - echo "Error: install.ps1 not found." - echo "Please download the full CCS package from:" - echo " https://github.com/kaitranntt/ccs" - exit 1 - fi -fi - -# Continue with Unix installation... - -# --- Helper Functions --- - -detect_current_provider() { - local settings="$CLAUDE_DIR/settings.json" - if [[ ! -f "$settings" ]]; then - echo "unknown" - return - fi - - if grep -q "api.kimi.com\|kimi-for-coding" "$settings" 2>/dev/null; then - echo "kimi" - elif grep -q "api.z.ai\|glm-4" "$settings" 2>/dev/null; then - echo "glm" - elif grep -q "ANTHROPIC_BASE_URL" "$settings" 2>/dev/null && ! grep -q "api.z.ai\|api.kimi.com" "$settings" 2>/dev/null; then - echo "custom" - else - echo "claude" - fi -} - -# --- Color/Format Functions (ANSI) --- -setup_colors() { - if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then - RED='\033[0;31m' - GREEN='\033[0;32m' - YELLOW='\033[1;33m' - CYAN='\033[0;36m' - BOLD='\033[1m' - RESET='\033[0m' - else - RED='' GREEN='' YELLOW='' CYAN='' BOLD='' RESET='' - fi -} - -msg_critical() { - echo "" >&2 - echo -e "${RED}${BOLD}╔═════════════════════════════════════════════╗${RESET}" >&2 - echo -e "${RED}${BOLD}║ ACTION REQUIRED ║${RESET}" >&2 - echo -e "${RED}${BOLD}╚═════════════════════════════════════════════╝${RESET}" >&2 - echo "" >&2 - echo -e "${RED}$1${RESET}" >&2 - echo "" >&2 -} - -msg_warning() { - echo "" >&2 - echo -e "${YELLOW}${BOLD}[!] WARNING${RESET}" >&2 - echo -e "${YELLOW}$1${RESET}" >&2 - echo "" >&2 -} - -msg_success() { - echo -e "${GREEN}[OK] $1${RESET}" -} - -msg_info() { - echo -e "[i] $1" -} - -msg_section() { - echo "" - echo -e "${BOLD}===== $1 =====${RESET}" - echo "" -} - -setup_colors - -# --- Node.js Detection (v4.5) --- -check_nodejs() { - if ! command -v node &> /dev/null; then - msg_warning "Node.js not found - - CCS v4.5+ requires Node.js 14+ to run. - The bootstrap scripts will check and install the npm package on first use. - - Install Node.js: https://nodejs.org (LTS recommended) - - Installation will continue, but 'ccs' will not work until Node.js is installed." - return 1 - fi - - local node_major - node_major=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) - if [[ $node_major -lt 14 ]]; then - msg_warning "Node.js 14+ required (found: $(node -v)) - - CCS v4.5+ requires Node.js 14 or newer. - Upgrade from: https://nodejs.org - - Installation will continue, but 'ccs' may not work correctly." - return 1 - fi - - msg_success "Node.js $(node -v) detected" - return 0 -} - -# --- Shell Profile Management --- - -detect_shell_profile() { - # Safe extraction of shell name (no command substitution) - local shell_path="${SHELL:-/bin/bash}" - local shell_name="${shell_path##*/}" - - # Validate shell_name is alphanumeric (defense in depth) - if [[ ! "$shell_name" =~ ^[a-zA-Z0-9_-]+$ ]]; then - shell_name="bash" - fi - - case "$shell_name" in - zsh) - echo "$HOME/.zshrc" - ;; - bash) - if [[ "$OSTYPE" == darwin* ]]; then - # macOS prefers bash_profile - [[ -f "$HOME/.bash_profile" ]] && echo "$HOME/.bash_profile" || echo "$HOME/.bashrc" - else - echo "$HOME/.bashrc" - fi - ;; - fish) - echo "$HOME/.config/fish/config.fish" - ;; - *) - # Default to bashrc - echo "$HOME/.bashrc" - ;; - esac -} - -check_path_configured() { - [[ ":$PATH:" == *":$HOME/.local/bin:"* ]] -} - -add_to_path() { - local profile_file="$1" - local dir_to_add="$HOME/.local/bin" - - # Create profile file if doesn't exist - if [[ ! -f "$profile_file" ]]; then - local profile_dir="$(dirname "$profile_file")" - - if ! mkdir -p "$profile_dir" 2>/dev/null; then - echo "[!] Failed to create directory: $profile_dir" >&2 - return 1 - fi - - if ! touch "$profile_file" 2>/dev/null; then - echo "[!] Failed to create profile file: $profile_file" >&2 - return 1 - fi - fi - - # Check if already in profile (avoid duplicates) - if grep -q "# CCS: Added by Claude Code Switch installer" "$profile_file" 2>/dev/null; then - return 0 # Already added - fi - - # Check for fish shell (different syntax) - if [[ "$profile_file" == *"config.fish" ]]; then - cat >> "$profile_file" << 'EOF' - -# CCS: Added by Claude Code Switch installer -set -gx PATH $HOME/.local/bin $PATH -EOF - else - # Bash/Zsh syntax - cat >> "$profile_file" << 'EOF' - -# CCS: Added by Claude Code Switch installer -export PATH="$HOME/.local/bin:$PATH" -EOF - fi - - return 0 -} - -configure_shell_path() { - if check_path_configured; then - msg_info "PATH already configured for ~/.local/bin" - return 0 - fi - - local profile_file=$(detect_shell_profile) - - echo "" - msg_section "Configuring Shell PATH" - msg_info "Detected shell profile: $profile_file" - - if add_to_path "$profile_file"; then - msg_success "Added ~/.local/bin to PATH in $profile_file" - echo "" - - # Show reload instructions - msg_critical "Reload your shell to use 'ccs' command: - - Option 1 (current session): - source $profile_file - - Option 2 (new session): - Open a new terminal window - - Then verify: - ccs --version" - - return 0 - else - msg_warning "Could not auto-configure PATH - - Manually add this line to $profile_file: - export PATH=\"\$HOME/.local/bin:\$PATH\" - - Then reload: - source $profile_file" - return 1 - fi -} - -create_glm_template() { - cat << EOF -{ - "env": { - "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic", - "ANTHROPIC_AUTH_TOKEN": "YOUR_GLM_API_KEY_HERE", - "ANTHROPIC_MODEL": "$GLM_MODEL", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "$GLM_MODEL", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "$GLM_MODEL", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "$GLM_MODEL" - } -} -EOF -} - -create_kimi_template() { - cat << EOF -{ - "env": { - "ANTHROPIC_BASE_URL": "https://api.kimi.com/coding/", - "ANTHROPIC_AUTH_TOKEN": "YOUR_KIMI_API_KEY_HERE", - "ANTHROPIC_MODEL": "$KIMI_MODEL", - "ANTHROPIC_SMALL_FAST_MODEL": "$KIMI_MODEL", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "$KIMI_MODEL", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "$KIMI_MODEL", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "$KIMI_MODEL" - }, - "alwaysThinkingEnabled": true -} -EOF -} - -atomic_mv() { - local src="$1" - local dest="$2" - if mv "$src" "$dest" 2>/dev/null; then - return 0 - else - rm -f "$src" - echo " [X] Error: Failed to create $dest (check permissions)" - exit 1 - fi -} - -download_file() { - local url="$1" - local dest="$2" - - if ! curl -fsSL "$url" -o "$dest"; then - echo " [!] Failed to download: $(basename "$dest")" - return 1 - fi - return 0 -} - -install_claude_folder() { - local source_dir="$1" - local target_dir="$CCS_DIR/.claude" - - # Check if already exists - if [[ -d "$target_dir" ]]; then - echo "| [i] .claude/ folder already exists, skipping" - return 0 - fi - - mkdir -p "$target_dir/commands" "$target_dir/skills/ccs-delegation/references" - - if [[ "$INSTALL_METHOD" == "git" ]]; then - # Copy from local git repo - if [[ -d "$source_dir/.claude" ]]; then - cp -r "$source_dir/.claude"/* "$target_dir/" 2>/dev/null || { - echo "| [!] Failed to copy .claude/ folder" - return 1 - } - echo "| [OK] Installed .claude/ folder" - else - echo "| [!] .claude/ folder not found in source" - return 1 - fi - else - # Standalone: download from GitHub - local base_url="https://raw.githubusercontent.com/kaitranntt/ccs/main/.claude" - - download_file "$base_url/commands/ccs.md" "$target_dir/commands/ccs.md" || return 1 - download_file "$base_url/skills/ccs-delegation/SKILL.md" "$target_dir/skills/ccs-delegation/SKILL.md" || return 1 - download_file "$base_url/skills/ccs-delegation/references/delegation-patterns.md" "$target_dir/skills/ccs-delegation/references/delegation-patterns.md" || return 1 - - echo "| [OK] Downloaded .claude/ folder" - fi - - return 0 -} - -create_glm_profile() { - local current_settings="$CLAUDE_DIR/settings.json" - local glm_settings="$CCS_DIR/glm.settings.json" - local provider="$1" - - if [[ "$provider" == "glm" ]]; then - echo "[OK] Copying current GLM config to profile..." - if command -v jq &> /dev/null; then - if jq '.env |= (. // {}) + { - "ANTHROPIC_DEFAULT_OPUS_MODEL": "'"$GLM_MODEL"'", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "'"$GLM_MODEL"'", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "'"$GLM_MODEL"'" - }' "$current_settings" > "$glm_settings.tmp" 2>/dev/null; then - atomic_mv "$glm_settings.tmp" "$glm_settings" - echo " Created: $glm_settings (with your existing API key + enhanced settings)" - else - rm -f "$glm_settings.tmp" - cp "$current_settings" "$glm_settings" - echo " Created: $glm_settings (copied as-is, jq enhancement failed)" - fi - else - cp "$current_settings" "$glm_settings" - echo " Created: $glm_settings (copied as-is, jq not available)" - fi - else - echo "Creating GLM profile template at $glm_settings" - if [[ -f "$current_settings" ]] && command -v jq &> /dev/null; then - if jq '.env |= (. // {}) + { - "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic", - "ANTHROPIC_AUTH_TOKEN": "YOUR_GLM_API_KEY_HERE", - "ANTHROPIC_MODEL": "'"$GLM_MODEL"'", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "'"$GLM_MODEL"'", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "'"$GLM_MODEL"'", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "'"$GLM_MODEL"'" - }' "$current_settings" > "$glm_settings.tmp" 2>/dev/null; then - atomic_mv "$glm_settings.tmp" "$glm_settings" - else - rm -f "$glm_settings.tmp" - echo " [i] jq failed, using basic template" - create_glm_template > "$glm_settings" - fi - else - create_glm_template > "$glm_settings" - fi - echo " Created: $glm_settings" - echo " [!] Edit this file and replace YOUR_GLM_API_KEY_HERE with your actual GLM API key" - fi -} - -create_kimi_profile() { - local current_settings="$CLAUDE_DIR/settings.json" - local kimi_settings="$CCS_DIR/kimi.settings.json" - local provider="$1" - - if [[ "$provider" == "kimi" ]]; then - echo "[OK] Copying current Kimi config to profile..." - if command -v jq &> /dev/null; then - if jq '.env |= (. // {}) + { - "ANTHROPIC_SMALL_FAST_MODEL": "'"$KIMI_MODEL"'", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "'"$KIMI_MODEL"'", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "'"$KIMI_MODEL"'", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "'"$KIMI_MODEL"'" - }' "$current_settings" > "$kimi_settings.tmp" 2>/dev/null; then - atomic_mv "$kimi_settings.tmp" "$kimi_settings" - echo " Created: $kimi_settings (with your existing API key + enhanced settings)" - else - rm -f "$kimi_settings.tmp" - cp "$current_settings" "$kimi_settings" - echo " Created: $kimi_settings (copied as-is, jq enhancement failed)" - fi - else - cp "$current_settings" "$kimi_settings" - echo " Created: $kimi_settings (copied as-is, jq not available)" - fi - else - echo "Creating Kimi profile template at $kimi_settings" - if [[ -f "$current_settings" ]] && command -v jq &> /dev/null; then - if jq '.env |= (. // {}) + { - "ANTHROPIC_BASE_URL": "https://api.kimi.com/coding/", - "ANTHROPIC_AUTH_TOKEN": "YOUR_KIMI_API_KEY_HERE", - "ANTHROPIC_MODEL": "'"$KIMI_MODEL"'", - "ANTHROPIC_SMALL_FAST_MODEL": "'"$KIMI_MODEL"'", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "'"$KIMI_MODEL"'", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "'"$KIMI_MODEL"'", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "'"$KIMI_MODEL"'" - } | . + {"alwaysThinkingEnabled": true}' "$current_settings" > "$kimi_settings.tmp" 2>/dev/null; then - atomic_mv "$kimi_settings.tmp" "$kimi_settings" - else - rm -f "$kimi_settings.tmp" - echo " [i] jq failed, using basic template" - create_kimi_template > "$kimi_settings" - fi - else - create_kimi_template > "$kimi_settings" - fi - echo " Created: $kimi_settings" - echo " [!] Edit this file and replace YOUR_KIMI_API_KEY_HERE with your actual Kimi API key" - fi -} - -# --- Main Installation --- - -# Check Node.js requirement (warn if missing, continue anyway) -check_nodejs || true - -echo "┌─ Installing CCS" - -# Create directories -mkdir -p "$INSTALL_DIR" "$CCS_DIR" - -# Install main executable -if [[ "$INSTALL_METHOD" == "standalone" ]]; then - # Standalone install - download ccs from GitHub - if ! command -v curl &> /dev/null; then - echo "[X] Error: curl is required for standalone installation" - exit 1 - fi - - BASE_URL="https://raw.githubusercontent.com/kaitranntt/ccs/main" - - # Download main executable - if curl -fsSL "$BASE_URL/lib/ccs" -o "$CCS_DIR/ccs"; then - chmod +x "$CCS_DIR/ccs" - ln -sf "$CCS_DIR/ccs" "$INSTALL_DIR/ccs" - echo "| [OK] Downloaded executable" - else - echo "|" - echo "[X] Error: Failed to download ccs from GitHub" - exit 1 - fi - - # Note: Shell dependencies (error-codes.sh, progress-indicator.sh, prompt.sh) no longer needed - # Bootstrap delegates all functionality to Node.js via npx - - # Download shell completion files - mkdir -p "$CCS_DIR/completions" - if curl -fsSL "$BASE_URL/scripts/completion/ccs.bash" -o "$CCS_DIR/completions/ccs.bash" 2>/dev/null; then - echo "| [OK] Downloaded completion files" - fi - curl -fsSL "$BASE_URL/scripts/completion/ccs.zsh" -o "$CCS_DIR/completions/ccs.zsh" 2>/dev/null || true - curl -fsSL "$BASE_URL/scripts/completion/ccs.fish" -o "$CCS_DIR/completions/ccs.fish" 2>/dev/null || true -else - # Git install - use local ccs file - # Handle both running from root or from installers/ subdirectory - local LIB_DIR="" - if [[ -f "$SCRIPT_DIR/lib/ccs" ]]; then - chmod +x "$SCRIPT_DIR/lib/ccs" - ln -sf "$SCRIPT_DIR/lib/ccs" "$INSTALL_DIR/ccs" - LIB_DIR="$SCRIPT_DIR/lib" - elif [[ -f "$SCRIPT_DIR/../lib/ccs" ]]; then - chmod +x "$SCRIPT_DIR/../lib/ccs" - ln -sf "$SCRIPT_DIR/../lib/ccs" "$INSTALL_DIR/ccs" - LIB_DIR="$SCRIPT_DIR/../lib" - else - echo "|" - echo "[X] Error: lib/ccs executable not found" - exit 1 - fi - echo "| [OK] Installed executable" - - # Note: Shell dependencies (error-codes.sh, progress-indicator.sh, prompt.sh) no longer needed - # Bootstrap delegates all functionality to Node.js via npx - - # Copy shell completion files - mkdir -p "$CCS_DIR/completions" - local COMPLETION_DIR="" - if [[ -d "$SCRIPT_DIR/scripts/completion" ]]; then - COMPLETION_DIR="$SCRIPT_DIR/scripts/completion" - elif [[ -d "$SCRIPT_DIR/../scripts/completion" ]]; then - COMPLETION_DIR="$SCRIPT_DIR/../scripts/completion" - fi - - if [[ -n "$COMPLETION_DIR" ]]; then - cp "$COMPLETION_DIR/ccs.bash" "$CCS_DIR/completions/ccs.bash" 2>/dev/null || true - cp "$COMPLETION_DIR/ccs.zsh" "$CCS_DIR/completions/ccs.zsh" 2>/dev/null || true - cp "$COMPLETION_DIR/ccs.fish" "$CCS_DIR/completions/ccs.fish" 2>/dev/null || true - echo "| [OK] Copied completion files" - fi -fi - -if [[ ! -L "$INSTALL_DIR/ccs" ]]; then - echo "|" - echo "[X] Error: Failed to create symlink at $INSTALL_DIR/ccs" - echo " Check directory permissions and try again." - exit 1 -fi - -# Install uninstall script (with idempotency check) -if [[ -f "$SCRIPT_DIR/uninstall.sh" ]]; then - # Only copy if source and destination are different - if [[ "$SCRIPT_DIR/uninstall.sh" != "$CCS_DIR/uninstall.sh" ]]; then - cp "$SCRIPT_DIR/uninstall.sh" "$CCS_DIR/uninstall.sh" - fi - chmod +x "$CCS_DIR/uninstall.sh" - ln -sf "$CCS_DIR/uninstall.sh" "$INSTALL_DIR/ccs-uninstall" - echo "| [OK] Installed uninstaller" -elif [[ "$INSTALL_METHOD" == "standalone" ]] && command -v curl &> /dev/null; then - if curl -fsSL https://raw.githubusercontent.com/kaitranntt/ccs/main/installers/uninstall.sh -o "$CCS_DIR/uninstall.sh"; then - chmod +x "$CCS_DIR/uninstall.sh" - ln -sf "$CCS_DIR/uninstall.sh" "$INSTALL_DIR/ccs-uninstall" - echo "| [OK] Installed uninstaller" - fi -fi - -echo "| [OK] Created directories" - -# Install .claude/ folder -if [[ "$INSTALL_METHOD" == "git" ]]; then - install_claude_folder "$SCRIPT_DIR/.." || echo "| [!] Optional .claude/ installation skipped" -else - install_claude_folder "" || echo "| [!] Optional .claude/ installation skipped" -fi - -echo "└─" -echo "" - -# --- Profile Setup --- - -CURRENT_PROVIDER=$(detect_current_provider) -GLM_SETTINGS="$CCS_DIR/glm.settings.json" -KIMI_SETTINGS="$CCS_DIR/kimi.settings.json" - -# Build provider label -PROVIDER_LABEL="" -[[ "$CURRENT_PROVIDER" == "glm" ]] && PROVIDER_LABEL=" (detected: GLM)" -[[ "$CURRENT_PROVIDER" == "kimi" ]] && PROVIDER_LABEL=" (detected: Kimi)" -[[ "$CURRENT_PROVIDER" == "claude" ]] && PROVIDER_LABEL=" (detected: Claude)" -[[ "$CURRENT_PROVIDER" == "custom" ]] && PROVIDER_LABEL=" (detected: custom)" - -echo "┌─ Configuring Profiles (v${CCS_VERSION})${PROVIDER_LABEL}" - -# Backup existing config if present (single backup, no timestamp) -BACKUP_FILE="$CCS_DIR/config.json.backup" -if [[ -f "$CCS_DIR/config.json" ]]; then - cp "$CCS_DIR/config.json" "$BACKUP_FILE" -fi - -# Track if GLM needs API key -NEEDS_GLM_KEY=false - -# Create GLM profile if missing -if [[ ! -f "$GLM_SETTINGS" ]]; then - create_glm_profile "$CURRENT_PROVIDER" >/dev/null 2>&1 - echo "| [OK] GLM profile -> ~/.ccs/glm.settings.json" - [[ "$CURRENT_PROVIDER" != "glm" ]] && NEEDS_GLM_KEY=true -fi - -# Track if Kimi needs API key -NEEDS_KIMI_KEY=false - -# Create Kimi profile if missing -if [[ ! -f "$KIMI_SETTINGS" ]]; then - create_kimi_profile "$CURRENT_PROVIDER" >/dev/null 2>&1 - echo "| [OK] Kimi profile -> ~/.ccs/kimi.settings.json" - [[ "$CURRENT_PROVIDER" != "kimi" ]] && NEEDS_KIMI_KEY=true -fi - -# Create config if missing -if [[ ! -f "$CCS_DIR/config.json" ]]; then - cat > "$CCS_DIR/config.json.tmp" << 'EOF' -{ - "profiles": { - "glm": "~/.ccs/glm.settings.json", - "kimi": "~/.ccs/kimi.settings.json", - "default": "~/.claude/settings.json" - } -} -EOF - atomic_mv "$CCS_DIR/config.json.tmp" "$CCS_DIR/config.json" - echo "| [OK] Config -> ~/.ccs/config.json" -fi - -# Validate config JSON -if [[ -f "$CCS_DIR/config.json" ]]; then - if command -v jq &> /dev/null; then - if ! jq -e . "$CCS_DIR/config.json" &>/dev/null; then - echo "| [!] Warning: Invalid JSON in config.json" - if [[ -f "$BACKUP_FILE" ]]; then - echo "| Restore from: $BACKUP_FILE" - fi - fi - fi -fi - -# Validate GLM settings JSON -if [[ -f "$GLM_SETTINGS" ]]; then - if command -v jq &> /dev/null; then - if ! jq -e . "$GLM_SETTINGS" &>/dev/null; then - echo "| [!] Warning: Invalid JSON in glm.settings.json" - fi - fi -fi - -echo "└─" -echo "" - -# Detect circular symlink -detect_circular_symlink() { - local target="$1" - local link_path="$2" - - # Check if target exists and is symlink - if [[ ! -L "$target" ]]; then - return 1 # Not circular - fi - - # Resolve target's link - local target_link=$(readlink "$target" 2>/dev/null || echo "") - local shared_dir="$HOME/.ccs/shared" - - # Check if target points back to our shared dir - if [[ "$target_link" == "$shared_dir"* ]] || [[ "$target_link" == "$link_path" ]]; then - echo "[!] Circular symlink detected: $target → $target_link" - return 0 # Circular - fi - - return 1 # Not circular -} - -# Setup shared directories as symlinks to ~/.claude/ (v3.2.0) -setup_shared_symlinks() { - local shared_dir="$CCS_DIR/shared" - local claude_dir="$HOME/.claude" - - # Create ~/.claude/ if missing - if [[ ! -d "$claude_dir" ]]; then - echo "[i] Creating ~/.claude/ directory structure" - mkdir -p "$claude_dir"/{commands,skills,agents} - fi - - # Create shared directory - mkdir -p "$shared_dir" - - # Create symlinks ~/.ccs/shared/* → ~/.claude/* - for dir in commands skills agents; do - local claude_path="$claude_dir/$dir" - local shared_path="$shared_dir/$dir" - - # Create directory in ~/.claude/ if missing - if [[ ! -d "$claude_path" ]]; then - mkdir -p "$claude_path" - fi - - # Check for circular symlink - if detect_circular_symlink "$claude_path" "$shared_path"; then - echo "[!] Skipping $dir: circular symlink detected" - continue - fi - - # If already correct symlink, skip - if [[ -L "$shared_path" ]]; then - local current_target=$(readlink "$shared_path" 2>/dev/null || echo "") - if [[ "$current_target" == "$claude_path" ]]; then - continue # Already correct - fi - rm -rf "$shared_path" - elif [[ -e "$shared_path" ]]; then - # Backup existing data before replacing - if [[ -d "$shared_path" ]] && [[ -n "$(ls -A "$shared_path" 2>/dev/null)" ]]; then - echo "[i] Migrating existing $dir to ~/.claude/$dir" - # Copy to claude dir (preserve user modifications) - for item in "$shared_path"/*; do - [[ -e "$item" ]] || continue - local basename=$(basename "$item") - if [[ ! -e "$claude_path/$basename" ]]; then - cp -r "$item" "$claude_path/" 2>/dev/null - fi - done - fi - rm -rf "$shared_path" - fi - - # Create symlink - ln -s "$claude_path" "$shared_path" 2>/dev/null || { - echo "[!] Failed to create symlink for $dir, copying instead" - mkdir -p "$shared_path" - if [[ -d "$claude_path" ]]; then - cp -r "$claude_path"/* "$shared_path/" 2>/dev/null || true - fi - } - done -} - -echo "[i] Setting up shared directories..." -setup_shared_symlinks -echo "" - -# Install CCS items to ~/.claude/ via symlinks (v4.1.0) -echo "[i] Installing CCS items to ~/.claude/..." -if command -v node &> /dev/null; then - # Check if .claude/ was successfully installed - if [[ -d "$CCS_DIR/.claude" ]]; then - # Download or copy claude-symlink-manager.js - mkdir -p "$CCS_DIR/bin/utils" - - if [[ "$INSTALL_METHOD" == "git" ]]; then - # Git install - copy from local repo - if [[ -f "$SCRIPT_DIR/../bin/utils/claude-symlink-manager.js" ]]; then - cp "$SCRIPT_DIR/../bin/utils/claude-symlink-manager.js" "$CCS_DIR/bin/utils/claude-symlink-manager.js" - elif [[ -f "$SCRIPT_DIR/bin/utils/claude-symlink-manager.js" ]]; then - cp "$SCRIPT_DIR/bin/utils/claude-symlink-manager.js" "$CCS_DIR/bin/utils/claude-symlink-manager.js" - fi - else - # Standalone install - download from GitHub - if ! curl -fsSL "https://raw.githubusercontent.com/kaitranntt/ccs/main/bin/utils/claude-symlink-manager.js" -o "$CCS_DIR/bin/utils/claude-symlink-manager.js" 2>/dev/null; then - echo "[!] Failed to download claude-symlink-manager.js" - fi - fi - - # Call ClaudeSymlinkManager if available - if [[ -f "$CCS_DIR/bin/utils/claude-symlink-manager.js" ]]; then - node -e " - try { - const ClaudeSymlinkManager = require('$CCS_DIR/bin/utils/claude-symlink-manager.js'); - const manager = new ClaudeSymlinkManager(); - manager.install(); - } catch (err) { - console.log('[!] CCS item installation warning: ' + err.message); - console.log(' Run \"ccs sync\" to retry'); - } - " 2>/dev/null || echo "[!] CCS item installation skipped (run 'ccs sync' later)" - else - echo "[!] claude-symlink-manager.js not found, skipping" - echo " Run 'ccs sync' after installation to complete setup" - fi - else - echo "[!] .claude/ folder not found, skipping CCS item installation" - fi -else - echo "[!] Node.js not found, skipping CCS item installation" - echo " Install Node.js and run 'ccs sync' to complete setup" -fi -echo "" - -# Auto-configure PATH if needed (all Unix platforms) -configure_shell_path - -# Show API key warning if needed -if [[ "$NEEDS_GLM_KEY" == "true" ]]; then - msg_critical "Configure GLM API Key: - - 1. Get API key from: https://api.z.ai - - 2. Edit: ~/.ccs/glm.settings.json - - 3. Replace: YOUR_GLM_API_KEY_HERE - With your actual API key - - 4. Test: ccs glm --version" -fi - -# Show API key warning for Kimi if needed -if [[ "$NEEDS_KIMI_KEY" == "true" ]]; then - msg_critical "Configure Kimi API Key: - - 1. Get API key from: https://www.kimi.com/coding - - 2. Edit: ~/.ccs/kimi.settings.json - - 3. Replace: YOUR_KIMI_API_KEY_HERE - With your actual API key - - 4. Test: ccs kimi --version" -fi - -msg_success "CCS installed successfully!" -echo "" -echo " Installed components:" -echo " * ccs command -> ~/.local/bin/ccs" -echo " * config -> ~/.ccs/config.json" -echo " * glm profile -> ~/.ccs/glm.settings.json" -echo " * kimi profile -> ~/.ccs/kimi.settings.json" -echo " * .claude/ folder -> ~/.ccs/.claude/" -echo "" -echo " Requirements:" -echo " * Node.js 14+ (detected: $(node -v 2>/dev/null || echo 'NOT FOUND'))" -echo " * npm 5.2+ (for npx, comes with Node.js 8.2+)" -echo "" -echo " First Run:" -echo " The first time you run 'ccs', it will automatically install" -echo " the @kaitranntt/ccs npm package globally via npx." -echo "" -echo " Quick start:" -echo " ccs # Use Claude subscription (default)" -echo " ccs glm # Use GLM fallback" -echo " ccs kimi # Use Kimi for Coding" -echo "" -echo " To uninstall: ccs-uninstall" -echo "" diff --git a/installers/uninstall.ps1 b/installers/uninstall.ps1 deleted file mode 100644 index fca77726..00000000 --- a/installers/uninstall.ps1 +++ /dev/null @@ -1,99 +0,0 @@ -# CCS Uninstallation Script (Windows PowerShell) -# https://github.com/kaitranntt/ccs - -$ErrorActionPreference = "Stop" - -# --- Color/Format Functions --- -function Write-Success { - param([string]$Message) - Write-Host "[OK] $Message" -ForegroundColor Green -} - -function Write-Info { - param([string]$Message) - Write-Host "[i] $Message" -ForegroundColor Cyan -} - -# --- Selective Cleanup Function --- -function Invoke-SelectiveCleanup { - param([string]$CcsDir) - - $Removed = @() - $Kept = @() - - # Remove executables and version metadata - $FilesToRemove = @("ccs.ps1", "VERSION") - - # Also remove the uninstall script itself - $UninstallScript = $PSCommandPath - if ($UninstallScript -and (Test-Path $UninstallScript)) { - $FilesToRemove += $UninstallScript - } - - foreach ($File in $FilesToRemove) { - $FilePath = if ([System.IO.Path]::IsPathRooted($File)) { $File } else { Join-Path $CcsDir $File } - if (Test-Path $FilePath) { - Remove-Item $FilePath -Force - $Removed += Split-Path $FilePath -Leaf - } - } - - # Remove .claude folder - if (Test-Path "$CcsDir\.claude") { - Remove-Item "$CcsDir\.claude" -Recurse -Force - $Removed += ".claude/" - } - - # Track kept files - if (Test-Path "$CcsDir\config.json") { $Kept += "config.json" } - if (Test-Path "$CcsDir\config.json.backup") { $Kept += "config.json.backup" } - Get-ChildItem "$CcsDir\*.settings.json" -ErrorAction SilentlyContinue | ForEach-Object { - $Kept += $_.Name - } - - # Report results - if ($Removed.Count -gt 0) { - Write-Info "Cleaned up: $($Removed -join ', ')" - } - - if ($Kept.Count -gt 0) { - Write-Info "Kept config files: $($Kept -join ', ')" - } -} - -Write-Host "Uninstalling ccs..." -Write-Host "" - -$CcsDir = "$env:USERPROFILE\.ccs" - -# Remove from PATH -$UserPath = [Environment]::GetEnvironmentVariable("Path", [System.EnvironmentVariableTarget]::User) -if ($UserPath -like "*$CcsDir*") { - try { - $NewPath = ($UserPath -split ';' | Where-Object { $_ -ne $CcsDir }) -join ';' - [Environment]::SetEnvironmentVariable("Path", $NewPath, [System.EnvironmentVariableTarget]::User) - Write-Success "Removed from PATH: $CcsDir" - Write-Host " Restart your terminal for changes to take effect." - } catch { - Write-Host "[!] Could not remove from PATH automatically. Please remove manually: $CcsDir" -ForegroundColor Yellow - } -} - -# Ask about ~/.ccs directory -if (Test-Path $CcsDir) { - Write-Host "" - $Response = Read-Host "Remove CCS directory $CcsDir`? This includes config and profiles. (y/N)" - if ($Response -match '^[Yy]$') { - Remove-Item $CcsDir -Recurse -Force - Write-Success "Removed: $CcsDir" - } else { - Write-Host "" - Invoke-SelectiveCleanup -CcsDir $CcsDir - } -} else { - Write-Info "No CCS directory found at $CcsDir" -} - -Write-Host "" -Write-Success "Uninstall complete!" -Write-Host "" diff --git a/installers/uninstall.sh b/installers/uninstall.sh deleted file mode 100755 index e0a407d8..00000000 --- a/installers/uninstall.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# --- Color/Format Functions --- -setup_colors() { - if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then - GREEN='\033[0;32m' - CYAN='\033[0;36m' - RESET='\033[0m' - else - GREEN='' CYAN='' RESET='' - fi -} - -msg_success() { - echo -e "${GREEN}[OK] $1${RESET}" -} - -msg_info() { - echo -e "${CYAN}[i] $1${RESET}" -} - -# --- Selective Cleanup Function --- -selective_cleanup() { - local ccs_dir="$1" - local removed=() - local kept=() - - # Remove executables, version metadata, and .claude folder - for file in "ccs" "uninstall.sh" "VERSION"; do - if [[ -f "$ccs_dir/$file" ]]; then - rm "$ccs_dir/$file" - removed+=("$file") - fi - done - - # Remove .claude folder - if [[ -d "$ccs_dir/.claude" ]]; then - rm -rf "$ccs_dir/.claude" - removed+=(".claude/") - fi - - # Track kept files - [[ -f "$ccs_dir/config.json" ]] && kept+=("config.json") - [[ -f "$ccs_dir/config.json.backup" ]] && kept+=("config.json.backup") - for settings in "$ccs_dir"/*.settings.json; do - [[ -f "$settings" ]] && kept+=("$(basename "$settings")") - done - - # Report results - if [[ ${#removed[@]} -gt 0 ]]; then - msg_info "Cleaned up: ${removed[*]}" - fi - - if [[ ${#kept[@]} -gt 0 ]]; then - msg_info "Kept config files: ${kept[*]}" - fi -} - -setup_colors - -echo "Uninstalling ccs..." -echo "" - -# Remove from ~/.local/bin (standard location) -if [[ -L "$HOME/.local/bin/ccs" ]]; then - rm "$HOME/.local/bin/ccs" - msg_success "Removed: $HOME/.local/bin/ccs" -elif [[ -f "$HOME/.local/bin/ccs" ]]; then - rm "$HOME/.local/bin/ccs" - msg_success "Removed: $HOME/.local/bin/ccs" -fi - -if [[ -L "$HOME/.local/bin/ccs-uninstall" ]]; then - rm "$HOME/.local/bin/ccs-uninstall" - msg_success "Removed: $HOME/.local/bin/ccs-uninstall" -fi - -# Ask about ~/.ccs directory -if [[ -d "$HOME/.ccs" ]]; then - read -p "Remove CCS directory ~/.ccs? This includes config and profiles. (y/N) " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]]; then - rm -rf "$HOME/.ccs" - msg_success "Removed: $HOME/.ccs" - else - echo "" - selective_cleanup "$HOME/.ccs" - fi -else - msg_info "No CCS directory found at $HOME/.ccs" -fi - -echo "" -msg_success "Uninstall complete!" diff --git a/package.json b/package.json index 19100fe2..f3413639 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.5.0", + "version": "6.6.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", @@ -75,8 +75,6 @@ "ui:build": "cd ui && bun run build", "ui:preview": "cd ui && bun run preview", "ui:validate": "cd ui && bun run validate", - "prepublishOnly": "node scripts/sync-version.js", - "prepack": "node scripts/sync-version.js", "prepare": "husky", "postinstall": "node scripts/postinstall.js" }, @@ -98,7 +96,11 @@ "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", "@semantic-release/changelog": "^6.0.3", + "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/git": "^10.0.1", + "@semantic-release/github": "^12.0.2", + "@semantic-release/npm": "^13.1.3", + "@semantic-release/release-notes-generator": "^14.1.0", "@tailwindcss/vite": "^4.1.17", "@types/chokidar": "^2.1.7", "@types/express": "^4.17.21", @@ -108,6 +110,7 @@ "@typescript-eslint/eslint-plugin": "^8.48.0", "@typescript-eslint/parser": "^8.48.0", "@vitejs/plugin-react": "^5.1.1", + "conventional-changelog-conventionalcommits": "^9.1.0", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "husky": "^9.1.7", diff --git a/scripts/send-discord-release.cjs b/scripts/send-discord-release.cjs new file mode 100644 index 00000000..b22da048 --- /dev/null +++ b/scripts/send-discord-release.cjs @@ -0,0 +1,214 @@ +/** + * Send Release Notification to Discord using Embeds + * + * Usage: + * node send-discord-release.cjs + * + * Args: + * type: 'production' or 'dev' + * webhook-url: Discord webhook URL + */ + +const fs = require('fs'); +const https = require('https'); +const { URL } = require('url'); + +const releaseType = process.argv[2]; // 'production' or 'dev' +const webhookUrl = process.argv[3]; + +if (!releaseType || !webhookUrl) { + console.error('Usage: node send-discord-release.cjs '); + process.exit(1); +} + +// Validate webhook URL is Discord +try { + const parsed = new URL(webhookUrl); + if (!parsed.hostname.endsWith('discord.com') || !parsed.pathname.startsWith('/api/webhooks/')) { + console.error('[X] Invalid Discord webhook URL'); + process.exit(1); + } +} catch { + console.error('[X] Invalid URL format'); + process.exit(1); +} + +/** + * Extract latest release from CHANGELOG.md + */ +function extractLatestRelease() { + const changelogPath = 'CHANGELOG.md'; + + if (!fs.existsSync(changelogPath)) { + return { + version: 'Unknown', + date: new Date().toISOString().split('T')[0], + sections: {}, + }; + } + + const content = fs.readFileSync(changelogPath, 'utf8'); + const lines = content.split('\n'); + + let version = 'Unknown'; + let date = new Date().toISOString().split('T')[0]; + let collecting = false; + let currentSection = null; + const sections = {}; + + for (const line of lines) { + // Match: ## [1.0.0](url) (2025-01-01) or ## 1.0.0 (2025-01-01) + const versionMatch = line.match(/^## \[?(\d+\.\d+\.\d+(?:-dev\.\d+)?)\]?.*?\((\d{4}-\d{2}-\d{2})\)/); + if (versionMatch) { + if (!collecting) { + version = versionMatch[1]; + date = versionMatch[2]; + collecting = true; + continue; + } else { + break; // Found next version, stop + } + } + + if (!collecting) continue; + + // Match section headers: ### Features, ### Bug Fixes + const sectionMatch = line.match(/^### (.+)/); + if (sectionMatch) { + currentSection = sectionMatch[1]; + sections[currentSection] = []; + continue; + } + + // Collect bullet points + if (currentSection && line.trim().startsWith('*')) { + const item = line.trim().substring(1).trim(); + if (item) { + sections[currentSection].push(item); + } + } + } + + return { version, date, sections }; +} + +/** + * Create Discord embed + */ +function createEmbed(release) { + const isDev = releaseType === 'dev'; + const color = isDev ? 0xf59e0b : 0x10b981; // Orange for dev, Green for production + const title = isDev ? `Dev Release ${release.version}` : `Release ${release.version}`; + const url = `https://github.com/kaitranntt/ccs/releases/tag/v${release.version}`; + + // Section name to indicator mapping (ASCII only per CLAUDE.md) + const sectionIndicators = { + Features: '[+]', + 'Bug Fixes': '[X]', + Documentation: '[i]', + Styles: '[~]', + 'Code Refactoring': '[~]', + 'Performance Improvements': '[!]', + Tests: '[T]', + 'Build System': '[B]', + CI: '[C]', + }; + + const fields = []; + + for (const [sectionName, items] of Object.entries(release.sections)) { + if (items.length === 0) continue; + + const indicator = sectionIndicators[sectionName] || '[*]'; + let fieldValue = items.map((item) => `• ${item}`).join('\n'); + + // Discord field value max is 1024 characters + if (fieldValue.length > 1024) { + const truncateAt = fieldValue.lastIndexOf('\n', 1000); + fieldValue = fieldValue.substring(0, truncateAt > 0 ? truncateAt : 1000) + '\n... *(truncated)*'; + } + + fields.push({ + name: `${indicator} ${sectionName}`, + value: fieldValue, + inline: false, + }); + } + + if (fields.length === 0) { + fields.push({ + name: '[i] Release Notes', + value: 'Release completed. See changelog on GitHub.', + inline: false, + }); + } + + return { + title, + url, + color, + timestamp: new Date().toISOString(), + footer: { + text: isDev ? 'npm i @kaitranntt/ccs@dev' : 'npm i @kaitranntt/ccs@latest', + }, + fields, + }; +} + +/** + * Send to Discord webhook + */ +function sendToDiscord(embed) { + const payload = { + username: releaseType === 'dev' ? 'CCS Dev Release' : 'CCS Release', + embeds: [embed], + }; + + const url = new URL(webhookUrl); + const options = { + hostname: url.hostname, + path: url.pathname + url.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }; + + const req = https.request(options, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + console.log('[OK] Discord notification sent'); + } else { + console.error(`[X] Discord webhook failed: ${res.statusCode}`); + console.error(data); + process.exit(1); + } + }); + }); + + req.on('error', (error) => { + console.error('[X] Error sending Discord notification:', error); + process.exit(1); + }); + + req.write(JSON.stringify(payload)); + req.end(); +} + +// Main +try { + const release = extractLatestRelease(); + console.log(`[i] Preparing ${releaseType} notification for v${release.version}`); + + const embed = createEmbed(release); + sendToDiscord(embed); +} catch (error) { + console.error('[X] Error:', error); + process.exit(1); +} diff --git a/scripts/sync-version-plugin.cjs b/scripts/sync-version-plugin.cjs deleted file mode 100644 index 90d17ca3..00000000 --- a/scripts/sync-version-plugin.cjs +++ /dev/null @@ -1,41 +0,0 @@ -/** - * semantic-release plugin to sync VERSION file - * - * semantic-release updates package.json but not the VERSION file. - * This plugin keeps VERSION in sync for shell scripts and installers. - */ -const fs = require('fs'); -const path = require('path'); - -module.exports = { - /** - * Called during the prepare step before git commit - */ - prepare(_pluginConfig, context) { - const { nextRelease, logger } = context; - const versionFile = path.join(process.cwd(), 'VERSION'); - - // Write version without 'v' prefix (e.g., "5.1.0" not "v5.1.0") - const version = nextRelease.version; - fs.writeFileSync(versionFile, version + '\n'); - logger.log('[sync-version-plugin] Updated VERSION file to %s', version); - - // Also update installers for standalone installs - const installSh = path.join(process.cwd(), 'installers', 'install.sh'); - const installPs1 = path.join(process.cwd(), 'installers', 'install.ps1'); - - if (fs.existsSync(installSh)) { - let content = fs.readFileSync(installSh, 'utf8'); - content = content.replace(/^CCS_VERSION=".*"/m, `CCS_VERSION="${version}"`); - fs.writeFileSync(installSh, content); - logger.log('[sync-version-plugin] Updated installers/install.sh'); - } - - if (fs.existsSync(installPs1)) { - let content = fs.readFileSync(installPs1, 'utf8'); - content = content.replace(/^\$CcsVersion = ".*"/m, `$CcsVersion = "${version}"`); - fs.writeFileSync(installPs1, content); - logger.log('[sync-version-plugin] Updated installers/install.ps1'); - } - } -}; diff --git a/scripts/sync-version.js b/scripts/sync-version.js deleted file mode 100755 index e0d4a42a..00000000 --- a/scripts/sync-version.js +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env node -const fs = require('fs'); -const path = require('path'); - -// Read VERSION file -const versionFile = path.join(__dirname, '..', 'VERSION'); -const version = fs.readFileSync(versionFile, 'utf8').trim(); - -// Update package.json -const pkgPath = path.join(__dirname, '..', 'package.json'); -const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); -pkg.version = version; -fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); - -console.log(`✓ Synced version ${version} to package.json`); \ No newline at end of file diff --git a/scripts/worker.js b/scripts/worker.js index ca0d7fdb..2e74b4f1 100644 --- a/scripts/worker.js +++ b/scripts/worker.js @@ -1,46 +1,26 @@ +/** + * CCS CloudFlare Worker - Redirect to npm Installation + * + * Legacy shell installers are deprecated. This worker now redirects + * all /install* and /uninstall* requests to the npm installation docs. + */ export default { async fetch(request) { const url = new URL(request.url); + const docsUrl = 'https://docs.ccs.kaitran.ca/getting-started/installation'; - // Detect platform from User-Agent header - const userAgent = request.headers.get('user-agent') || ''; - const isWindows = userAgent.includes('Windows') || userAgent.includes('Win32'); - const isPowerShell = userAgent.includes('PowerShell') || userAgent.includes('pwsh'); - - // Smart routing with platform detection - let filePath; - if (url.pathname === '/install' || url.pathname === '/install.sh') { - filePath = (isWindows && isPowerShell) ? 'installers/install.ps1' : 'installers/install.sh'; - } else if (url.pathname === '/install.ps1') { - filePath = 'installers/install.ps1'; - } else if (url.pathname === '/uninstall' || url.pathname === '/uninstall.sh') { - filePath = (isWindows && isPowerShell) ? 'installers/uninstall.ps1' : 'installers/uninstall.sh'; - } else if (url.pathname === '/uninstall.ps1') { - filePath = 'installers/uninstall.ps1'; - } else { - return new Response('Not Found', { status: 404 }); + // Redirect all install/uninstall paths to npm installation docs + if ( + url.pathname === '/install' || + url.pathname === '/install.sh' || + url.pathname === '/install.ps1' || + url.pathname === '/uninstall' || + url.pathname === '/uninstall.sh' || + url.pathname === '/uninstall.ps1' + ) { + return Response.redirect(docsUrl, 301); } - try { - const githubUrl = `https://raw.githubusercontent.com/kaitranntt/ccs/main/${filePath}`; - const response = await fetch(githubUrl); - - if (!response.ok) { - return new Response('File not found on GitHub', { status: 404 }); - } - - const contentType = filePath.endsWith('.ps1') - ? 'text/plain; charset=utf-8' - : 'text/x-shellscript; charset=utf-8'; - - return new Response(response.body, { - headers: { - 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=300' - } - }); - } catch (error) { - return new Response('Server Error', { status: 500 }); - } + return new Response('Not Found', { status: 404 }); } }; \ No newline at end of file diff --git a/src/ccs.ts b/src/ccs.ts index 2477cfb4..894defab 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -2,7 +2,7 @@ import { spawn, ChildProcess } from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; import { detectClaudeCli } from './utils/claude-detector'; -import { getSettingsPath } from './utils/config-manager'; +import { getSettingsPath, loadSettings } from './utils/config-manager'; import { ErrorManager } from './utils/error-manager'; import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; import { @@ -11,6 +11,7 @@ import { displayWebSearchStatus, getWebSearchHookEnv, } from './utils/websearch-manager'; +import { getGlobalEnvConfig } from './config/unified-config-loader'; // Import extracted command handlers import { handleVersionCommand } from './commands/version-command'; @@ -32,7 +33,7 @@ import { checkCachedUpdate, isCacheStale, } from './utils/update-checker'; -import { detectInstallationMethod } from './utils/package-manager-detector'; +// Note: npm is now the only supported installation method // ========== Profile Detection ========== @@ -219,9 +220,8 @@ interface ProfileError extends Error { async function refreshUpdateCache(): Promise { try { const currentVersion = getVersion(); - const installMethod = detectInstallationMethod(); - // Force=true to always fetch fresh data - await checkForUpdates(currentVersion, true, installMethod); + // npm is now the only supported installation method + await checkForUpdates(currentVersion, true, 'npm'); } catch (_e) { // Silently fail - update check shouldn't crash main CLI } @@ -495,7 +495,20 @@ async function main(): Promise { // Use --settings flag (backward compatible) const expandedSettingsPath = getSettingsPath(profileInfo.name); const webSearchEnv = getWebSearchHookEnv(); + // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles + const globalEnvConfig = getGlobalEnvConfig(); + const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; + + // CRITICAL: Load settings and explicitly set ANTHROPIC_* env vars + // to prevent inheriting stale values from previous CLIProxy sessions. + // Environment variables take precedence over --settings file values, + // so we must explicitly set them here to ensure correct routing. + const settings = loadSettings(expandedSettingsPath); + const settingsEnv = settings.env || {}; + const envVars: NodeJS.ProcessEnv = { + ...globalEnv, + ...settingsEnv, // Explicitly inject all settings env vars ...webSearchEnv, CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider }; diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 8137c108..fa037060 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -19,7 +19,8 @@ import * as crypto from 'crypto'; import * as zlib from 'zlib'; import { ProgressIndicator } from '../utils/progress-indicator'; import { ok, info } from '../utils/ui'; -import { getBinDir, getCliproxyDir } from './config-generator'; +import { getBinDir, getCliproxyDir, CLIPROXY_DEFAULT_PORT } from './config-generator'; +import { isCliproxyRunning } from './stats-fetcher'; import { BinaryInfo, BinaryManagerConfig, @@ -58,6 +59,7 @@ interface UpdateCheckResult { currentVersion: string; latestVersion: string; fromCache: boolean; + checkedAt: number; // Unix timestamp of last check } /** Default configuration */ @@ -103,17 +105,31 @@ export class BinaryManager { try { const updateResult = await this.checkForUpdates(); if (updateResult.hasUpdate) { - console.log( - info( - `CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}` - ) - ); - console.log(info('Updating CLIProxyAPI...')); + // Check if CLIProxyAPI is currently running - can't update while running + const proxyRunning = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT); + if (proxyRunning) { + // Proxy is running - can't update, just notify user + console.log( + info( + `CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}` + ) + ); + console.log(info('Run "ccs cliproxy stop" then restart to apply update')); + this.log('Skipping update: CLIProxyAPI is currently running'); + } else { + // Proxy not running - safe to update + console.log( + info( + `CLIProxyAPI update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}` + ) + ); + console.log(info('Updating CLIProxyAPI...')); - // Delete old binary and download new version - this.deleteBinary(); - this.config.version = updateResult.latestVersion; - await this.downloadAndInstall(); + // Delete old binary and download new version + this.deleteBinary(); + this.config.version = updateResult.latestVersion; + await this.downloadAndInstall(); + } } } catch (error) { // Silent fail - don't block startup if update check fails @@ -157,19 +173,21 @@ export class BinaryManager { const currentVersion = this.getInstalledVersion(); // Try cache first - const cachedVersion = this.getCachedLatestVersion(); - if (cachedVersion) { - this.log(`Using cached version: ${cachedVersion}`); + const cache = this.getVersionCache(); + if (cache) { + this.log(`Using cached version: ${cache.latestVersion}`); return { - hasUpdate: this.isNewerVersion(cachedVersion, currentVersion), + hasUpdate: this.isNewerVersion(cache.latestVersion, currentVersion), currentVersion, - latestVersion: cachedVersion, + latestVersion: cache.latestVersion, fromCache: true, + checkedAt: cache.checkedAt, }; } // Fetch from GitHub API const latestVersion = await this.fetchLatestVersion(); + const now = Date.now(); this.cacheLatestVersion(latestVersion); return { @@ -177,6 +195,7 @@ export class BinaryManager { currentVersion, latestVersion, fromCache: false, + checkedAt: now, }; } @@ -272,9 +291,9 @@ export class BinaryManager { } /** - * Get cached latest version if still valid + * Get version cache data if still valid */ - private getCachedLatestVersion(): string | null { + private getVersionCache(): VersionCache | null { const cachePath = this.getVersionCachePath(); if (!fs.existsSync(cachePath)) { return null; @@ -286,7 +305,7 @@ export class BinaryManager { // Check if cache is still valid if (Date.now() - cache.checkedAt < VERSION_CACHE_DURATION_MS) { - return cache.latestVersion; + return cache; } // Cache expired @@ -980,6 +999,24 @@ export async function fetchLatestCliproxyVersion(): Promise { return result.latestVersion; } +/** Update check result for API response */ +export interface CliproxyUpdateCheckResult { + hasUpdate: boolean; + currentVersion: string; + latestVersion: string; + fromCache: boolean; + checkedAt: number; // Unix timestamp of last check +} + +/** + * Check for CLIProxyAPI binary updates + * @returns Update check result with version info + */ +export async function checkCliproxyUpdate(): Promise { + const manager = new BinaryManager(); + return manager.checkForUpdates(); +} + /** * Get path to version pin file * @returns Absolute path to .version-pin file diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index bef6a2fa..9cd4277f 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -21,13 +21,17 @@ import { ensureCLIProxyBinary } from './binary-manager'; import { generateConfig, getEffectiveEnvVars, + getRemoteEnvVars, getProviderConfig, ensureProviderSettings, CLIPROXY_DEFAULT_PORT, + getCliproxyWritablePath, } from './config-generator'; +import { checkRemoteProxy } from './remote-proxy-client'; import { isAuthenticated } from './auth-handler'; import { CLIProxyProvider, ExecutorConfig } from './types'; import { configureProviderModel, getCurrentModel } from './model-config'; +import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; import { @@ -125,6 +129,22 @@ export async function execClaudeWithCLIProxy( } }; + // 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults) + // This filters proxy flags from args and returns resolved config + const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args); + + // Use resolved port from proxy config (overrides ExecutorConfig) + if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) { + cfg.port = proxyConfig.port; + } + + log(`Proxy mode: ${proxyConfig.mode}`); + if (proxyConfig.mode === 'remote') { + log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`); + } + + // Note: proxyConfig is available for Phase 4 (remote mode integration) + // Ensure MCP web-search is configured for third-party profiles // WebSearch is a server-side tool executed by Anthropic's API // Third-party providers don't have access, so we use MCP fallback @@ -141,39 +161,102 @@ export async function execClaudeWithCLIProxy( const providerConfig = getProviderConfig(provider); log(`Provider: ${providerConfig.displayName}`); - // 1. Ensure binary exists (downloads if needed) - const spinner = new ProgressIndicator('Preparing CLIProxy'); - spinner.start(); + // Check remote proxy if configured (before binary download) + let useRemoteProxy = false; + if (proxyConfig.mode === 'remote' && proxyConfig.host) { + const status = await checkRemoteProxy({ + host: proxyConfig.host, + port: proxyConfig.port, + protocol: proxyConfig.protocol, + authToken: proxyConfig.authToken, + timeout: 2000, + allowSelfSigned: proxyConfig.protocol === 'https', + }); - let binaryPath: string; - try { - binaryPath = await ensureCLIProxyBinary(verbose); - spinner.succeed('CLIProxy binary ready'); - } catch (error) { - spinner.fail('Failed to prepare CLIProxy'); - throw error; + if (status.reachable) { + useRemoteProxy = true; + console.log( + ok( + `Connected to remote proxy at ${proxyConfig.host}:${proxyConfig.port} (${status.latencyMs}ms)` + ) + ); + } else { + console.error(warn(`Remote proxy unreachable: ${status.error}`)); + + if (proxyConfig.remoteOnly) { + throw new Error('Remote proxy unreachable and --remote-only specified'); + } + + if (proxyConfig.fallbackEnabled) { + if (proxyConfig.autoStartLocal) { + console.log(info('Falling back to local proxy...')); + } else { + // Prompt user for fallback (only in TTY) + if (process.stdin.isTTY) { + const readline = await import('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => { + rl.question('Start local proxy instead? [Y/n] ', resolve); + }); + rl.close(); + if (answer.toLowerCase() === 'n') { + throw new Error('Remote proxy unreachable and user declined fallback'); + } + } + console.log(info('Starting local proxy...')); + } + } else { + throw new Error('Remote proxy unreachable and fallback disabled'); + } + } } - // 2. Handle special flags - const forceAuth = args.includes('--auth'); - const forceHeadless = args.includes('--headless'); - const forceLogout = args.includes('--logout'); - const forceConfig = args.includes('--config'); - const addAccount = args.includes('--add'); - const showAccounts = args.includes('--accounts'); + // Variables for local proxy mode + let binaryPath: string | undefined; + let sessionId: string | undefined; + + // 1. Ensure binary exists (downloads if needed) - SKIP for remote mode + if (!useRemoteProxy) { + const spinner = new ProgressIndicator('Preparing CLIProxy'); + spinner.start(); + + try { + binaryPath = await ensureCLIProxyBinary(verbose); + spinner.succeed('CLIProxy binary ready'); + } catch (error) { + spinner.fail('Failed to prepare CLIProxy'); + throw error; + } + } + + // 2. Handle special flags (use argsWithoutProxy - proxy flags already stripped) + const forceAuth = argsWithoutProxy.includes('--auth'); + const forceHeadless = argsWithoutProxy.includes('--headless'); + const forceLogout = argsWithoutProxy.includes('--logout'); + const forceConfig = argsWithoutProxy.includes('--config'); + const addAccount = argsWithoutProxy.includes('--add'); + const showAccounts = argsWithoutProxy.includes('--accounts'); // Parse --use flag let useAccount: string | undefined; - const useIdx = args.indexOf('--use'); - if (useIdx !== -1 && args[useIdx + 1] && !args[useIdx + 1].startsWith('-')) { - useAccount = args[useIdx + 1]; + const useIdx = argsWithoutProxy.indexOf('--use'); + if ( + useIdx !== -1 && + argsWithoutProxy[useIdx + 1] && + !argsWithoutProxy[useIdx + 1].startsWith('-') + ) { + useAccount = argsWithoutProxy[useIdx + 1]; } // Parse --nickname flag let setNickname: string | undefined; - const nicknameIdx = args.indexOf('--nickname'); - if (nicknameIdx !== -1 && args[nicknameIdx + 1] && !args[nicknameIdx + 1].startsWith('-')) { - setNickname = args[nicknameIdx + 1]; + const nicknameIdx = argsWithoutProxy.indexOf('--nickname'); + if ( + nicknameIdx !== -1 && + argsWithoutProxy[nicknameIdx + 1] && + !argsWithoutProxy[nicknameIdx + 1].startsWith('-') + ) { + setNickname = argsWithoutProxy[nicknameIdx + 1]; } // Handle --accounts: list accounts and exit @@ -305,118 +388,135 @@ export async function execClaudeWithCLIProxy( // 6. Ensure user settings file exists (creates from defaults if not) ensureProviderSettings(provider); - // 6. Generate config file - log(`Generating config for ${provider}`); - const configPath = generateConfig(provider, cfg.port); - log(`Config written: ${configPath}`); - - // 6a. Pre-flight check: handle existing proxy or port conflicts - // Clean up orphaned sessions first (from crashed proxies) - cleanupOrphanedSessions(cfg.port); - - // Check if there's an existing healthy proxy we can reuse - const existingProxy = getExistingProxy(cfg.port); + // Local proxy mode: generate config, spawn proxy, track session let proxy: ChildProcess | null = null; - let sessionId: string; - if (existingProxy) { - // Reuse existing proxy - another CCS session started it - log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`); - sessionId = registerSession(cfg.port, existingProxy.pid); - console.log( - info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`) - ); - } else { - // No existing proxy - check if port is free - const portProcess = await getPortProcess(cfg.port); - if (portProcess) { - if (isCLIProxyProcess(portProcess)) { - // CLIProxy on port but no session lock - likely orphaned/zombie - // Only kill if no active sessions registered - if (!hasActiveSessions()) { - log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`); - const killed = killProcessOnPort(cfg.port, verbose); - if (killed) { - console.log(info(`Cleaned up zombie CLIProxy process`)); - // Wait a bit for port to be released - await new Promise((r) => setTimeout(r, 500)); + if (!useRemoteProxy) { + // 6. Generate config file + log(`Generating config for ${provider}`); + const configPath = generateConfig(provider, cfg.port); + log(`Config written: ${configPath}`); + + // 6a. Pre-flight check: handle existing proxy or port conflicts + // Clean up orphaned sessions first (from crashed proxies) + cleanupOrphanedSessions(cfg.port); + + // Check if there's an existing healthy proxy we can reuse + const existingProxy = getExistingProxy(cfg.port); + + if (existingProxy) { + // Reuse existing proxy - another CCS session started it + log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`); + sessionId = registerSession(cfg.port, existingProxy.pid); + console.log( + info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`) + ); + } else { + // No existing proxy - check if port is free + const portProcess = await getPortProcess(cfg.port); + if (portProcess) { + if (isCLIProxyProcess(portProcess)) { + // CLIProxy on port but no session lock - likely orphaned/zombie + // Only kill if no active sessions registered + if (!hasActiveSessions()) { + log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`); + const killed = killProcessOnPort(cfg.port, verbose); + if (killed) { + console.log(info(`Cleaned up zombie CLIProxy process`)); + // Wait a bit for port to be released + await new Promise((r) => setTimeout(r, 500)); + } + } else { + // Active sessions exist but getExistingProxy returned null - something's wrong + // Try to connect anyway + log(`CLIProxy on port ${cfg.port} has active sessions, attempting to join...`); } } else { - // Active sessions exist but getExistingProxy returned null - something's wrong - // Try to connect anyway - log(`CLIProxy on port ${cfg.port} has active sessions, attempting to join...`); + // Non-CLIProxy process blocking the port - warn user + console.error(''); + console.error( + warn( + `Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})` + ) + ); + console.error(''); + console.error('To fix this, close the blocking application or run:'); + console.error(` ${getPortCheckCommand(cfg.port)}`); + console.error(''); + throw new Error(`Port ${cfg.port} is in use by another application`); } - } else { - // Non-CLIProxy process blocking the port - warn user - console.error(''); - console.error( - warn(`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`) - ); - console.error(''); - console.error('To fix this, close the blocking application or run:'); - console.error(` ${getPortCheckCommand(cfg.port)}`); - console.error(''); - throw new Error(`Port ${cfg.port} is in use by another application`); } + + // 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy) + // Use detached mode so proxy persists after terminal closes + const configPath = generateConfig(provider, cfg.port); + const proxyArgs = ['--config', configPath]; + + log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); + + proxy = spawn(binaryPath as string, proxyArgs, { + stdio: ['ignore', 'ignore', 'ignore'], + detached: true, // Persist after parent terminal closes + env: { + ...process.env, + WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/ + }, + }); + + // Unref so parent process can exit independently + proxy.unref(); + + // Handle proxy errors (only fires if spawn itself fails) + proxy.on('error', (error) => { + console.error(fail(`CLIProxy spawn error: ${error.message}`)); + }); + + // 7. Wait for proxy readiness via TCP polling + const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`); + readySpinner.start(); + + try { + await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval); + readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`); + } catch (error) { + readySpinner.fail('CLIProxy startup failed'); + proxy.kill('SIGTERM'); + + const err = error as Error; + console.error(''); + console.error(fail('CLIProxy failed to start')); + console.error(''); + console.error('Possible causes:'); + console.error(` 1. Port ${cfg.port} already in use`); + console.error(' 2. Binary crashed on startup'); + console.error(' 3. Invalid configuration'); + console.error(''); + console.error('Troubleshooting:'); + console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`); + console.error(' - Run with --verbose for detailed logs'); + console.error(` - View config: ${getCatCommand(configPath)}`); + console.error(' - Try: ccs doctor --fix'); + console.error(''); + + throw new Error(`CLIProxy startup failed: ${err.message}`); + } + + // Register this session with the new proxy + sessionId = registerSession(cfg.port, proxy.pid as number); + log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`); } - - // 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy) - // Use detached mode so proxy persists after terminal closes - const proxyArgs = ['--config', configPath]; - - log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); - - proxy = spawn(binaryPath, proxyArgs, { - stdio: ['ignore', 'ignore', 'ignore'], - detached: true, // Persist after parent terminal closes - }); - - // Unref so parent process can exit independently - proxy.unref(); - - // Handle proxy errors (only fires if spawn itself fails) - proxy.on('error', (error) => { - console.error(fail(`CLIProxy spawn error: ${error.message}`)); - }); - - // 7. Wait for proxy readiness via TCP polling - const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`); - readySpinner.start(); - - try { - await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval); - readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`); - } catch (error) { - readySpinner.fail('CLIProxy startup failed'); - proxy.kill('SIGTERM'); - - const err = error as Error; - console.error(''); - console.error(fail('CLIProxy failed to start')); - console.error(''); - console.error('Possible causes:'); - console.error(` 1. Port ${cfg.port} already in use`); - console.error(' 2. Binary crashed on startup'); - console.error(' 3. Invalid configuration'); - console.error(''); - console.error('Troubleshooting:'); - console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`); - console.error(' - Run with --verbose for detailed logs'); - console.error(` - View config: ${getCatCommand(configPath)}`); - console.error(' - Try: ccs doctor --fix'); - console.error(''); - - throw new Error(`CLIProxy startup failed: ${err.message}`); - } - - // Register this session with the new proxy - sessionId = registerSession(cfg.port, proxy.pid as number); - log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`); } // 7. Execute Claude CLI with proxied environment - // Uses custom settings path (for variants), user settings, or bundled defaults - const envVars = getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath); + // Use remote or local env vars based on mode + const envVars = useRemoteProxy + ? getRemoteEnvVars(provider, { + host: proxyConfig.host ?? 'localhost', + port: proxyConfig.port, + protocol: proxyConfig.protocol, + authToken: proxyConfig.authToken, + }) + : getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath); const webSearchEnv = getWebSearchHookEnv(); const env = { ...process.env, @@ -432,6 +532,7 @@ export async function execClaudeWithCLIProxy( } // Filter out CCS-specific flags before passing to Claude CLI + // Note: Proxy flags (--proxy-host, etc.) already stripped by resolveProxyConfig() const ccsFlags = [ '--auth', '--headless', @@ -441,12 +542,15 @@ export async function execClaudeWithCLIProxy( '--accounts', '--use', '--nickname', + // Proxy flags are handled by resolveProxyConfig, but list for documentation + ...PROXY_CLI_FLAGS, ]; - const claudeArgs = args.filter((arg, idx) => { + const claudeArgs = argsWithoutProxy.filter((arg, idx) => { // Filter out CCS flags if (ccsFlags.includes(arg)) return false; // Filter out value after --use or --nickname - if (args[idx - 1] === '--use' || args[idx - 1] === '--nickname') return false; + if (argsWithoutProxy[idx - 1] === '--use' || argsWithoutProxy[idx - 1] === '--nickname') + return false; return true; }); @@ -470,14 +574,16 @@ export async function execClaudeWithCLIProxy( }); } - // 8. Cleanup: unregister session when Claude exits + // 8. Cleanup: unregister session when Claude exits (local mode only) // Proxy persists by default - use 'ccs cliproxy stop' to kill manually claude.on('exit', (code, signal) => { log(`Claude exited: code=${code}, signal=${signal}`); - // Unregister this session (proxy keeps running for persistence) - unregisterSession(sessionId); - log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`); + // Unregister this session (proxy keeps running for persistence) - only for local mode + if (sessionId) { + unregisterSession(sessionId); + log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`); + } if (signal) { process.kill(process.pid, signal as NodeJS.Signals); @@ -489,8 +595,10 @@ export async function execClaudeWithCLIProxy( claude.on('error', (error) => { console.error(fail(`Claude CLI error: ${error}`)); - // Unregister session, proxy keeps running - unregisterSession(sessionId); + // Unregister session, proxy keeps running (local mode only) + if (sessionId) { + unregisterSession(sessionId); + } process.exit(1); }); @@ -498,8 +606,10 @@ export async function execClaudeWithCLIProxy( const cleanup = () => { log('Parent signal received, cleaning up'); - // Unregister session, proxy keeps running - unregisterSession(sessionId); + // Unregister session, proxy keeps running (local mode only) + if (sessionId) { + unregisterSession(sessionId); + } claude.kill('SIGTERM'); }; diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index 3383d313..9cf354d6 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -14,7 +14,7 @@ import { getCcsDir } from '../utils/config-manager'; import { warn } from '../utils/ui'; import { CLIProxyProvider, ProviderConfig, ProviderModelMapping } from './types'; import { getModelMappingFromConfig, getEnvVarsFromConfig } from './base-config-loader'; -import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; +import { loadOrCreateUnifiedConfig, getGlobalEnvConfig } from '../config/unified-config-loader'; /** Settings file structure for user overrides */ interface ProviderSettings { @@ -30,6 +30,15 @@ export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed'; /** Simple secret key for Control Panel login (user-facing) */ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; +/** + * Get CLIProxy writable directory for logs and runtime files. + * This directory is set as WRITABLE_PATH env var when spawning CLIProxy. + * Logs will be stored in ~/.ccs/cliproxy/logs/ + */ +export function getCliproxyWritablePath(): string { + return path.join(getCcsDir(), 'cliproxy'); +} + /** * Config version - bump when config format changes to trigger regeneration * v1: Initial config (port, auth-dir, api-keys only) @@ -371,6 +380,18 @@ export function getClaudeEnvVars( }; } +/** + * Get global env vars to inject into all third-party profiles. + * Returns empty object if disabled. + */ +function getGlobalEnvVars(): Record { + const globalEnvConfig = getGlobalEnvConfig(); + if (!globalEnvConfig.enabled) { + return {}; + } + return globalEnvConfig.env; +} + /** * Get effective environment variables for provider * @@ -379,7 +400,7 @@ export function getClaudeEnvVars( * 2. User settings file (~/.ccs/{provider}.settings.json) if exists * 3. Bundled defaults from PROVIDER_CONFIGS * - * This allows users to customize model mappings without code changes. + * All results are merged with global_env vars (telemetry/reporting disables). * User takes full responsibility for custom settings. */ export function getEffectiveEnvVars( @@ -387,6 +408,9 @@ export function getEffectiveEnvVars( port: number = CLIPROXY_DEFAULT_PORT, customSettingsPath?: string ): NodeJS.ProcessEnv { + // Get global env vars (DISABLE_TELEMETRY, etc.) + const globalEnv = getGlobalEnvVars(); + // Priority 1: Custom settings path (for user-defined variants) if (customSettingsPath) { const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir()); @@ -396,8 +420,8 @@ export function getEffectiveEnvVars( const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { - // Custom variant settings found - use them - return settings.env; + // Custom variant settings found - merge with global env + return { ...globalEnv, ...settings.env }; } } catch { // Invalid JSON - fall through to provider defaults @@ -418,9 +442,8 @@ export function getEffectiveEnvVars( const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { - // User override found - use their settings - // Note: User is responsible for correctness - return settings.env; + // User override found - merge with global env + return { ...globalEnv, ...settings.env }; } } catch { // Invalid JSON or structure - fall through to defaults @@ -428,8 +451,8 @@ export function getEffectiveEnvVars( } } - // No override or invalid - use bundled defaults - return getClaudeEnvVars(provider, port); + // No override or invalid - use bundled defaults merged with global env + return { ...globalEnv, ...getClaudeEnvVars(provider, port) }; } /** @@ -456,3 +479,49 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { mode: 0o600, }); } + +/** + * Get environment variables for remote proxy mode. + * Uses the remote proxy's provider endpoint as the base URL. + * + * @param provider CLIProxy provider (gemini, codex, agy, qwen, iflow) + * @param remoteConfig Remote proxy connection details + * @returns Environment variables for Claude CLI + */ +export function getRemoteEnvVars( + provider: CLIProxyProvider, + remoteConfig: { host: string; port: number; protocol: 'http' | 'https'; authToken?: string } +): Record { + const baseUrl = `${remoteConfig.protocol}://${remoteConfig.host}:${remoteConfig.port}/api/provider/${provider}`; + const models = getModelMapping(provider); + + // Get global env vars (DISABLE_TELEMETRY, etc.) + const globalEnv = getGlobalEnvVars(); + + // Get additional env vars from base config (ANTHROPIC_MAX_TOKENS, etc.) + const baseEnvVars = getEnvVarsFromConfig(provider); + + // Filter out core env vars from base config to avoid conflicts + const { + ANTHROPIC_BASE_URL: _baseUrl, + ANTHROPIC_AUTH_TOKEN: _authToken, + ANTHROPIC_MODEL: _model, + ANTHROPIC_DEFAULT_OPUS_MODEL: _opusModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnetModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: _haikuModel, + ...additionalEnvVars + } = baseEnvVars; + + const env: Record = { + ...globalEnv, + ...additionalEnvVars, + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || CCS_INTERNAL_API_KEY, + ANTHROPIC_MODEL: models.claudeModel, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel, + }; + + return env; +} diff --git a/src/cliproxy/proxy-config-resolver.ts b/src/cliproxy/proxy-config-resolver.ts new file mode 100644 index 00000000..b0af403f --- /dev/null +++ b/src/cliproxy/proxy-config-resolver.ts @@ -0,0 +1,279 @@ +/** + * Proxy Config Resolver + * + * Resolves proxy configuration from multiple sources with priority: + * CLI flags > Environment variables > config.yaml > defaults + * + * Supports both local (spawn CLIProxyAPI) and remote (connect to external) modes. + */ + +import { ResolvedProxyConfig } from './types'; +import { CLIPROXY_DEFAULT_PORT } from './config-generator'; + +/** CLI flags for proxy configuration */ +export const PROXY_CLI_FLAGS = [ + '--proxy-host', + '--proxy-port', + '--proxy-protocol', + '--proxy-auth-token', + '--local-proxy', + '--remote-only', +] as const; + +/** Environment variable names for proxy configuration */ +export const PROXY_ENV_VARS = { + host: 'CCS_PROXY_HOST', + port: 'CCS_PROXY_PORT', + protocol: 'CCS_PROXY_PROTOCOL', + authToken: 'CCS_PROXY_AUTH_TOKEN', + fallbackEnabled: 'CCS_PROXY_FALLBACK_ENABLED', +} as const; + +/** Parsed CLI proxy flags */ +interface ParsedProxyFlags { + host?: string; + port?: number; + protocol?: 'http' | 'https'; + authToken?: string; + localProxy: boolean; + remoteOnly: boolean; +} + +/** Proxy config from environment variables */ +interface EnvProxyConfig { + host?: string; + port?: number; + protocol?: 'http' | 'https'; + authToken?: string; + fallbackEnabled?: boolean; +} + +/** + * Parse proxy-related CLI flags from argv. + * Returns parsed flags and remaining args (with proxy flags removed). + */ +export function parseProxyFlags(args: string[]): { + flags: ParsedProxyFlags; + remainingArgs: string[]; +} { + const flags: ParsedProxyFlags = { + localProxy: false, + remoteOnly: false, + }; + const remainingArgs: string[] = []; + + let i = 0; + while (i < args.length) { + const arg = args[i]; + + if (arg === '--proxy-host' && args[i + 1] && !args[i + 1].startsWith('-')) { + flags.host = args[i + 1]; + i += 2; + continue; + } + + if (arg === '--proxy-port' && args[i + 1] && !args[i + 1].startsWith('-')) { + const port = parseInt(args[i + 1], 10); + if (!isNaN(port) && port > 0 && port <= 65535) { + flags.port = port; + } + i += 2; + continue; + } + + if (arg === '--proxy-protocol' && args[i + 1] && !args[i + 1].startsWith('-')) { + const proto = args[i + 1].toLowerCase(); + if (proto === 'http' || proto === 'https') { + flags.protocol = proto; + } + i += 2; + continue; + } + + if (arg === '--proxy-auth-token' && args[i + 1] && !args[i + 1].startsWith('-')) { + flags.authToken = args[i + 1]; + i += 2; + continue; + } + + if (arg === '--local-proxy') { + flags.localProxy = true; + i += 1; + continue; + } + + if (arg === '--remote-only') { + flags.remoteOnly = true; + i += 1; + continue; + } + + // Not a proxy flag - keep in remaining args + remainingArgs.push(arg); + i += 1; + } + + return { flags, remainingArgs }; +} + +/** + * Get proxy configuration from environment variables. + */ +export function getProxyEnvVars(): EnvProxyConfig { + const config: EnvProxyConfig = {}; + + const host = process.env[PROXY_ENV_VARS.host]; + if (host) { + config.host = host; + } + + const port = process.env[PROXY_ENV_VARS.port]; + if (port) { + const portNum = parseInt(port, 10); + if (!isNaN(portNum) && portNum > 0 && portNum <= 65535) { + config.port = portNum; + } + } + + const protocol = process.env[PROXY_ENV_VARS.protocol]; + if (protocol) { + const proto = protocol.toLowerCase(); + if (proto === 'http' || proto === 'https') { + config.protocol = proto; + } + } + + const authToken = process.env[PROXY_ENV_VARS.authToken]; + if (authToken) { + config.authToken = authToken; + } + + const fallback = process.env[PROXY_ENV_VARS.fallbackEnabled]; + if (fallback !== undefined) { + // Accept: '1', 'true', 'yes' as enabled; '0', 'false', 'no' as disabled + const lower = fallback.toLowerCase(); + if (lower === '1' || lower === 'true' || lower === 'yes') { + config.fallbackEnabled = true; + } else if (lower === '0' || lower === 'false' || lower === 'no') { + config.fallbackEnabled = false; + } + } + + return config; +} + +/** + * Default proxy configuration values. + */ +const DEFAULT_PROXY_CONFIG: ResolvedProxyConfig = { + mode: 'local', + port: CLIPROXY_DEFAULT_PORT, + protocol: 'http', + fallbackEnabled: true, + autoStartLocal: true, + remoteOnly: false, + forceLocal: false, +}; + +/** + * Resolve proxy configuration with priority: CLI > ENV > config.yaml > defaults. + * + * @param cliArgs - Raw CLI arguments + * @param configYamlProxy - Proxy section from config.yaml (optional, Phase 1) + * @returns Resolved configuration and remaining args (without proxy flags) + */ +export function resolveProxyConfig( + cliArgs: string[], + + _configYamlProxy?: { + remote?: { + enabled?: boolean; + host?: string; + port?: number; + protocol?: 'http' | 'https'; + auth_token?: string; + fallback_enabled?: boolean; + }; + local?: { + port?: number; + auto_start?: boolean; + }; + } +): { config: ResolvedProxyConfig; remainingArgs: string[] } { + // 1. Parse CLI flags (highest priority) + const { flags: cliFlags, remainingArgs } = parseProxyFlags(cliArgs); + + // 2. Get environment variables + const envConfig = getProxyEnvVars(); + + // 3. config.yaml proxy section (passed as parameter - Phase 1 provides this) + // For now, we use empty object if not provided; Phase 1 integrates unified config loading + const yamlConfig = _configYamlProxy || {}; + + // 4. Build resolved config with priority merge + const resolved: ResolvedProxyConfig = { + ...DEFAULT_PROXY_CONFIG, + }; + + // Determine mode: remote if host is specified anywhere (unless --local-proxy) + const hasRemoteHost = + cliFlags.host || envConfig.host || yamlConfig.remote?.host || yamlConfig.remote?.enabled; + + // --local-proxy forces local mode regardless of remote config + if (cliFlags.localProxy) { + resolved.mode = 'local'; + resolved.forceLocal = true; + } else if (hasRemoteHost) { + resolved.mode = 'remote'; + } + + // Merge host: CLI > ENV > config.yaml + resolved.host = cliFlags.host ?? envConfig.host ?? yamlConfig.remote?.host; + + // Merge port: CLI > ENV > config.yaml (remote or local) > default + resolved.port = + cliFlags.port ?? + envConfig.port ?? + (resolved.mode === 'remote' ? yamlConfig.remote?.port : yamlConfig.local?.port) ?? + DEFAULT_PROXY_CONFIG.port; + + // Merge protocol: CLI > ENV > config.yaml > default + resolved.protocol = + cliFlags.protocol ?? envConfig.protocol ?? yamlConfig.remote?.protocol ?? 'http'; + + // Merge auth token: CLI > ENV > config.yaml + resolved.authToken = cliFlags.authToken ?? envConfig.authToken ?? yamlConfig.remote?.auth_token; + + // Merge fallback enabled: ENV > config.yaml > default + resolved.fallbackEnabled = + envConfig.fallbackEnabled ?? yamlConfig.remote?.fallback_enabled ?? true; + + // --remote-only from CLI + resolved.remoteOnly = cliFlags.remoteOnly; + + // If --remote-only, disable fallback + if (resolved.remoteOnly) { + resolved.fallbackEnabled = false; + } + + // Auto-start local from config.yaml > default + resolved.autoStartLocal = yamlConfig.local?.auto_start ?? true; + + return { config: resolved, remainingArgs }; +} + +/** + * Check if args contain any proxy flags. + * Used for quick filtering before full parse. + */ +export function hasProxyFlags(args: string[]): boolean { + return args.some( + (arg) => + arg === '--proxy-host' || + arg === '--proxy-port' || + arg === '--proxy-protocol' || + arg === '--proxy-auth-token' || + arg === '--local-proxy' || + arg === '--remote-only' + ); +} diff --git a/src/cliproxy/remote-proxy-client.ts b/src/cliproxy/remote-proxy-client.ts new file mode 100644 index 00000000..c4c8a321 --- /dev/null +++ b/src/cliproxy/remote-proxy-client.ts @@ -0,0 +1,236 @@ +/** + * Remote Proxy Client for CLIProxyAPI + * + * HTTP client for health checks and connection testing against remote CLIProxyAPI instances. + * Uses native fetch API with aggressive timeout for CLI responsiveness. + */ + +import * as https from 'https'; + +/** Error codes for remote proxy status */ +export type RemoteProxyErrorCode = 'CONNECTION_REFUSED' | 'TIMEOUT' | 'AUTH_FAILED' | 'UNKNOWN'; + +/** Status returned from remote proxy health check */ +export interface RemoteProxyStatus { + /** Whether the remote proxy is reachable */ + reachable: boolean; + /** Latency in milliseconds (only set if reachable) */ + latencyMs?: number; + /** Error message (only set if not reachable) */ + error?: string; + /** Error code for programmatic handling */ + errorCode?: RemoteProxyErrorCode; +} + +/** Configuration for remote proxy client */ +export interface RemoteProxyClientConfig { + /** Remote proxy host (IP or hostname) */ + host: string; + /** Remote proxy port */ + port: number; + /** Protocol to use (http or https) */ + protocol: 'http' | 'https'; + /** Optional auth token for Authorization header */ + authToken?: string; + /** Request timeout in ms (default: 2000) */ + timeout?: number; + /** Allow self-signed certificates (default: false) */ + allowSelfSigned?: boolean; +} + +/** Default timeout for remote proxy requests (aggressive for CLI UX) */ +const DEFAULT_TIMEOUT_MS = 2000; + +/** + * Map error to RemoteProxyErrorCode + */ +function mapErrorToCode(error: Error, statusCode?: number): RemoteProxyErrorCode { + const message = error.message.toLowerCase(); + const code = (error as NodeJS.ErrnoException).code?.toLowerCase(); + + // Connection refused + if (code === 'econnrefused' || message.includes('connection refused')) { + return 'CONNECTION_REFUSED'; + } + + // Timeout + if ( + code === 'etimedout' || + code === 'timeout' || + message.includes('timeout') || + message.includes('aborted') + ) { + return 'TIMEOUT'; + } + + // Auth failed (401/403) + if (statusCode === 401 || statusCode === 403) { + return 'AUTH_FAILED'; + } + + return 'UNKNOWN'; +} + +/** + * Get human-readable error message from error code + */ +function getErrorMessage(errorCode: RemoteProxyErrorCode, rawError?: string): string { + switch (errorCode) { + case 'CONNECTION_REFUSED': + return 'Connection refused - is the proxy running?'; + case 'TIMEOUT': + return 'Connection timed out'; + case 'AUTH_FAILED': + return 'Authentication failed - check auth token'; + default: + return rawError || 'Unknown error'; + } +} + +/** + * Create a custom HTTPS agent for self-signed certificate support + */ +function createHttpsAgent(allowSelfSigned: boolean): https.Agent | undefined { + if (!allowSelfSigned) return undefined; + + return new https.Agent({ + rejectUnauthorized: false, + }); +} + +/** + * Check health of remote CLIProxyAPI instance + * + * @param config Remote proxy client configuration + * @returns RemoteProxyStatus with reachability and latency + */ +export async function checkRemoteProxy( + config: RemoteProxyClientConfig +): Promise { + const { host, port, protocol, authToken, allowSelfSigned = false } = config; + const timeout = config.timeout ?? DEFAULT_TIMEOUT_MS; + + const url = `${protocol}://${host}:${port}/health`; + const startTime = Date.now(); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + // Build request options + const headers: Record = { + Accept: 'application/json', + }; + + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + // For HTTPS with self-signed certs, we need to use native https module + // Bun's fetch doesn't support custom agents + let response: Response; + + if (protocol === 'https' && allowSelfSigned) { + // Warn about security implications + console.error('[!] Allowing self-signed certificate - not recommended for production'); + + // Use native https module for self-signed cert support + response = await new Promise((resolve, reject) => { + const agent = createHttpsAgent(true); + const reqTimeout = setTimeout(() => { + reject(new Error('Request timeout')); + }, timeout); + + const req = https.request( + url, + { + method: 'GET', + headers, + agent, + timeout, + }, + (res) => { + clearTimeout(reqTimeout); + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + resolve( + new Response(data, { + status: res.statusCode || 500, + statusText: res.statusMessage, + }) + ); + }); + } + ); + + req.on('error', (err) => { + clearTimeout(reqTimeout); + reject(err); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + + req.end(); + }); + } else { + // Standard fetch for HTTP or HTTPS without self-signed + response = await fetch(url, { + signal: controller.signal, + headers, + }); + } + + clearTimeout(timeoutId); + + const latencyMs = Date.now() - startTime; + + // Check for auth failure + if (response.status === 401 || response.status === 403) { + return { + reachable: false, + error: getErrorMessage('AUTH_FAILED'), + errorCode: 'AUTH_FAILED', + }; + } + + // 200 OK = healthy + if (response.ok) { + return { + reachable: true, + latencyMs, + }; + } + + // Non-200 but connected + return { + reachable: false, + error: `Unexpected status: ${response.status}`, + errorCode: 'UNKNOWN', + }; + } catch (error) { + const err = error as Error; + const errorCode = mapErrorToCode(err); + + return { + reachable: false, + error: getErrorMessage(errorCode, err.message), + errorCode, + }; + } +} + +/** + * Test connection to remote CLIProxyAPI (alias for dashboard use) + * + * This is an alias for checkRemoteProxy() for semantic clarity in UI contexts. + * + * @param config Remote proxy client configuration + * @returns RemoteProxyStatus with reachability and latency + */ +export async function testConnection(config: RemoteProxyClientConfig): Promise { + return checkRemoteProxy(config); +} diff --git a/src/cliproxy/service-manager.ts b/src/cliproxy/service-manager.ts index a515dcc0..f6977a07 100644 --- a/src/cliproxy/service-manager.ts +++ b/src/cliproxy/service-manager.ts @@ -19,6 +19,7 @@ import { regenerateConfig, configNeedsRegeneration, CLIPROXY_DEFAULT_PORT, + getCliproxyWritablePath, } from './config-generator'; import { isCliproxyRunning } from './stats-fetcher'; @@ -171,6 +172,10 @@ export async function ensureCliproxyService( proxyProcess = spawn(binaryPath, proxyArgs, { stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'], detached: true, // Allow process to run independently + env: { + ...process.env, + WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/ + }, }); // Forward output in verbose mode diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index aaf00058..e7506a74 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -271,6 +271,92 @@ export async function fetchCliproxyModels( } } +/** Error log file metadata from CLIProxyAPI */ +export interface CliproxyErrorLog { + /** Filename (e.g., "error-v1-chat-completions-2025-01-15T10-30-00.log") */ + name: string; + /** File size in bytes */ + size: number; + /** Last modified timestamp (Unix seconds) */ + modified: number; + /** Absolute path to the log file (injected by backend) */ + absolutePath?: string; +} + +/** Response from /v0/management/request-error-logs endpoint */ +interface ErrorLogsApiResponse { + files: CliproxyErrorLog[]; +} + +/** + * Fetch error log file list from CLIProxyAPI management API + * @param port CLIProxyAPI port (default: 8317) + * @returns Array of error log metadata or null if unavailable + */ +export async function fetchCliproxyErrorLogs( + port: number = CLIPROXY_DEFAULT_PORT +): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); + + const response = await fetch(`http://127.0.0.1:${port}/v0/management/request-error-logs`, { + signal: controller.signal, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`, + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as ErrorLogsApiResponse; + return data.files ?? []; + } catch { + return null; + } +} + +/** + * Fetch error log file content from CLIProxyAPI management API + * @param name Error log filename + * @param port CLIProxyAPI port (default: 8317) + * @returns Log file content as string or null if unavailable + */ +export async function fetchCliproxyErrorLogContent( + name: string, + port: number = CLIPROXY_DEFAULT_PORT +): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const response = await fetch( + `http://127.0.0.1:${port}/v0/management/request-error-logs/${encodeURIComponent(name)}`, + { + signal: controller.signal, + headers: { + Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`, + }, + } + ); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + return await response.text(); + } catch { + return null; + } +} + /** * Check if CLIProxyAPI is running and responsive * @param port CLIProxyAPI port (default: 8317) diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 925842d0..9fc1c1a9 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -187,3 +187,28 @@ export interface ProviderConfig { /** Whether OAuth is required */ requiresOAuth: boolean; } + +/** + * Resolved proxy configuration after merging CLI > ENV > config.yaml > defaults. + * Used by executor to determine local vs remote proxy mode. + */ +export interface ResolvedProxyConfig { + /** Proxy mode: 'local' spawns CLIProxyAPI locally, 'remote' connects to external server */ + mode: 'local' | 'remote'; + /** Remote proxy hostname/IP (only for remote mode) */ + host?: string; + /** Proxy port (default: 8317) */ + port: number; + /** Protocol for remote connection (default: http) */ + protocol: 'http' | 'https'; + /** Auth token for remote proxy authentication */ + authToken?: string; + /** Enable fallback to local when remote unreachable (default: true) */ + fallbackEnabled: boolean; + /** Auto-start local proxy if not running (default: true) */ + autoStartLocal: boolean; + /** --remote-only flag: fail if remote unreachable, no fallback */ + remoteOnly: boolean; + /** --local-proxy flag: force local mode, ignore remote config */ + forceLocal: boolean; +} diff --git a/src/commands/cleanup-command.ts b/src/commands/cleanup-command.ts index bae4e192..20c23cfa 100644 --- a/src/commands/cleanup-command.ts +++ b/src/commands/cleanup-command.ts @@ -2,6 +2,7 @@ * Cleanup Command Handler * * Removes old CLIProxy logs to free up disk space. + * Supports both main logs and error request logs with age-based filtering. * Logs can accumulate to several GB without user awareness. */ @@ -10,6 +11,9 @@ import * as path from 'path'; import { getCliproxyDir } from '../cliproxy/config-generator'; import { info, ok, warn } from '../utils/ui'; +/** Default age in days for error log cleanup */ +const DEFAULT_ERROR_LOG_AGE_DAYS = 7; + /** Get the CLIProxy logs directory */ function getLogsDir(): string { return path.join(getCliproxyDir(), 'logs'); @@ -95,6 +99,76 @@ function cleanDirectory(dirPath: string): { deleted: number; freedBytes: number return { deleted, freedBytes }; } +/** Error log file info */ +interface ErrorLogInfo { + name: string; + path: string; + size: number; + mtime: Date; + ageInDays: number; +} + +/** Get error log files with metadata */ +function getErrorLogFiles(logsDir: string): ErrorLogInfo[] { + if (!fs.existsSync(logsDir)) return []; + + const now = Date.now(); + const files: ErrorLogInfo[] = []; + const entries = fs.readdirSync(logsDir); + + for (const entry of entries) { + // Only process error-*.log files + if (!entry.startsWith('error-') || !entry.endsWith('.log')) continue; + + const filePath = path.join(logsDir, entry); + try { + const stats = fs.lstatSync(filePath); + if (stats.isFile() && !stats.isSymbolicLink()) { + const ageMs = now - stats.mtime.getTime(); + files.push({ + name: entry, + path: filePath, + size: stats.size, + mtime: stats.mtime, + ageInDays: Math.floor(ageMs / (1000 * 60 * 60 * 24)), + }); + } + } catch { + // File may have been deleted - skip + } + } + + // Sort by age, oldest first + return files.sort((a, b) => b.ageInDays - a.ageInDays); +} + +/** Delete error logs older than specified days */ +function cleanErrorLogs( + logsDir: string, + maxAgeDays: number +): { deleted: number; freedBytes: number; kept: number } { + const files = getErrorLogFiles(logsDir); + let deleted = 0; + let freedBytes = 0; + let kept = 0; + + for (const file of files) { + if (file.ageInDays >= maxAgeDays) { + try { + fs.unlinkSync(file.path); + deleted++; + freedBytes += file.size; + } catch { + // File may be locked or already deleted + } + } else { + kept++; + } + } + + return { deleted, freedBytes, kept }; +} + /** Print help for cleanup command */ function printHelp(): void { console.log(''); @@ -103,20 +177,19 @@ function printHelp(): void { console.log('Remove old CLIProxy logs to free up disk space.'); console.log(''); console.log('Options:'); + console.log(' --errors Clean error request logs (error-*.log files)'); + console.log(' --days=N Delete error logs older than N days (default: 7)'); console.log(' --dry-run Show what would be deleted without deleting'); console.log(' --force Skip confirmation prompt'); console.log(' --help, -h Show this help message'); console.log(''); console.log('Examples:'); - console.log(' ccs cleanup Interactive cleanup with confirmation'); - console.log(' ccs cleanup --dry-run Preview cleanup without deleting'); - console.log(' ccs cleanup --force Clean without confirmation'); - console.log(''); - console.log('Note: CLIProxy logging is disabled by default.'); - console.log('To enable logging, edit ~/.ccs/config.yaml:'); - console.log(' cliproxy:'); - console.log(' logging:'); - console.log(' enabled: true'); + console.log(' ccs cleanup Interactive main log cleanup'); + console.log(' ccs cleanup --errors Clean error logs older than 7 days'); + console.log(' ccs cleanup --errors --days=3 Clean error logs older than 3 days'); + console.log(' ccs cleanup --errors --dry-run Preview error log cleanup'); + console.log(' ccs cleanup --dry-run Preview main log cleanup'); + console.log(' ccs cleanup --force Clean main logs without confirmation'); console.log(''); } @@ -132,8 +205,125 @@ export async function handleCleanupCommand(args: string[]): Promise { const dryRun = args.includes('--dry-run'); const force = args.includes('--force'); + const cleanErrors = args.includes('--errors'); const logsDir = getLogsDir(); + // Parse --days=N option + let maxAgeDays = DEFAULT_ERROR_LOG_AGE_DAYS; + const daysArg = args.find((arg) => arg.startsWith('--days=')); + if (daysArg) { + const parsed = parseInt(daysArg.split('=')[1], 10); + if (isNaN(parsed) || parsed < 1) { + console.log(warn('Invalid --days value. Must be a positive integer.')); + return; + } + maxAgeDays = parsed; + } + + // Route to error log cleanup or main log cleanup + if (cleanErrors) { + await handleErrorLogCleanup(logsDir, maxAgeDays, dryRun, force); + } else { + await handleMainLogCleanup(logsDir, dryRun, force); + } +} + +/** + * Handle error log cleanup (error-*.log files) + */ +async function handleErrorLogCleanup( + logsDir: string, + maxAgeDays: number, + dryRun: boolean, + force: boolean +): Promise { + // Check if logs directory exists + if (!fs.existsSync(logsDir)) { + console.log(info('No CLIProxy logs directory found.')); + return; + } + + // Get error log files + const errorLogs = getErrorLogFiles(logsDir); + if (errorLogs.length === 0) { + console.log(info('No error logs found.')); + return; + } + + // Calculate what would be deleted + const toDelete = errorLogs.filter((f) => f.ageInDays >= maxAgeDays); + const toKeep = errorLogs.filter((f) => f.ageInDays < maxAgeDays); + const totalDeleteSize = toDelete.reduce((sum, f) => sum + f.size, 0); + + console.log(''); + console.log(`Error Logs: ${logsDir}`); + console.log(` Total: ${errorLogs.length} files`); + console.log( + ` To delete: ${toDelete.length} files older than ${maxAgeDays} days (${formatBytes(totalDeleteSize)})` + ); + console.log(` To keep: ${toKeep.length} files newer than ${maxAgeDays} days`); + console.log(''); + + if (toDelete.length === 0) { + console.log(info(`No error logs older than ${maxAgeDays} days.`)); + return; + } + + // Show oldest files in dry-run or verbose mode + if (dryRun || toDelete.length <= 5) { + console.log('Files to delete:'); + for (const file of toDelete.slice(0, 10)) { + console.log(` ${file.name} (${file.ageInDays}d old, ${formatBytes(file.size)})`); + } + if (toDelete.length > 10) { + console.log(` ... and ${toDelete.length - 10} more`); + } + console.log(''); + } + + if (dryRun) { + console.log(info('Dry run - no files deleted.')); + return; + } + + // Confirm unless --force + if (!force) { + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const answer = await new Promise((resolve) => { + rl.question( + `Delete ${toDelete.length} error logs older than ${maxAgeDays} days (${formatBytes(totalDeleteSize)})? [y/N] `, + resolve + ); + }); + rl.close(); + + if (answer.toLowerCase() !== 'y') { + console.log('Cancelled.'); + return; + } + } + + // Perform cleanup + const { deleted, freedBytes, kept } = cleanErrorLogs(logsDir, maxAgeDays); + console.log(ok(`Deleted ${deleted} error logs, freed ${formatBytes(freedBytes)}`)); + if (kept > 0) { + console.log(info(`Kept ${kept} recent error logs (less than ${maxAgeDays} days old)`)); + } +} + +/** + * Handle main log cleanup (main.log and rotated files) + */ +async function handleMainLogCleanup( + logsDir: string, + dryRun: boolean, + force: boolean +): Promise { // Check if logs directory exists if (!fs.existsSync(logsDir)) { console.log(info('No CLIProxy logs found.')); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 618dfd23..4490126b 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -241,6 +241,25 @@ Claude Code Profile & Model Switcher`.trim(); ['ccs cliproxy --latest', 'Update to latest version'], ]); + // CLI Proxy configuration flags (new) + printSubSection('CLI Proxy Configuration', [ + ['--proxy-host ', 'Remote proxy hostname/IP'], + ['--proxy-port ', 'Proxy port (default: 8317)'], + ['--proxy-protocol ', 'Protocol: http or https (default: http)'], + ['--proxy-auth-token ', 'Auth token for remote proxy'], + ['--local-proxy', 'Force local mode, ignore remote config'], + ['--remote-only', 'Fail if remote unreachable (no fallback)'], + ]); + + // CLI Proxy env vars + printSubSection('CLI Proxy Environment Variables', [ + ['CCS_PROXY_HOST', 'Remote proxy hostname'], + ['CCS_PROXY_PORT', 'Proxy port'], + ['CCS_PROXY_PROTOCOL', 'Protocol (http/https)'], + ['CCS_PROXY_AUTH_TOKEN', 'Auth token'], + ['CCS_PROXY_FALLBACK_ENABLED', 'Enable local fallback (1/0)'], + ]); + // CLI Proxy paths console.log(subheader('CLI Proxy:')); console.log(` Binary: ${color('~/.ccs/cliproxy/bin/cli-proxy-api', 'path')}`); diff --git a/src/commands/update-command.ts b/src/commands/update-command.ts index 5cef805a..a172e4da 100644 --- a/src/commands/update-command.ts +++ b/src/commands/update-command.ts @@ -2,12 +2,12 @@ * Update Command Handler * * Handles `ccs update` command - checks for updates and installs latest version. - * Supports both npm and direct installation methods. + * Uses npm/yarn/pnpm/bun package managers exclusively. */ import { spawn } from 'child_process'; import { initUI, header, ok, fail, warn, info, color } from '../utils/ui'; -import { detectInstallationMethod, detectPackageManager } from '../utils/package-manager-detector'; +import { detectPackageManager } from '../utils/package-manager-detector'; import { compareVersionsWithPrerelease } from '../utils/update-checker'; import { getVersion } from '../utils/version'; @@ -35,33 +35,20 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< console.log(header('Checking for updates...')); console.log(''); - const installMethod = detectInstallationMethod(); - const isNpmInstall = installMethod === 'npm'; - // Force reinstall - skip update check if (force) { console.log(info(`Force reinstall from @${targetTag} channel...`)); console.log(''); - - if (isNpmInstall) { - await performNpmUpdate(targetTag, true); - } else { - // Direct install doesn't support --beta - if (beta) { - handleDirectBetaNotSupported(); - return; - } - await performDirectUpdate(); - } + await performNpmUpdate(targetTag, true); return; } const { checkForUpdates } = await import('../utils/update-checker'); - const updateResult = await checkForUpdates(CCS_VERSION, true, installMethod, targetTag); + const updateResult = await checkForUpdates(CCS_VERSION, true, 'npm', targetTag); if (updateResult.status === 'check_failed') { - handleCheckFailed(updateResult.message ?? 'Update check failed', isNpmInstall, targetTag); + handleCheckFailed(updateResult.message ?? 'Update check failed', targetTag); return; } @@ -102,21 +89,13 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< console.log(''); } - if (isNpmInstall) { - await performNpmUpdate(targetTag); - } else { - await performDirectUpdate(); - } + await performNpmUpdate(targetTag); } /** * Handle failed update check */ -function handleCheckFailed( - message: string, - isNpmInstall: boolean, - targetTag: string = 'latest' -): void { +function handleCheckFailed(message: string, targetTag: string = 'latest'): void { console.log(fail(message)); console.log(''); console.log(warn('Possible causes:')); @@ -126,36 +105,27 @@ function handleCheckFailed( console.log(''); console.log('Try again later or update manually:'); - if (isNpmInstall) { - const packageManager = detectPackageManager(); - let manualCommand: string; + const packageManager = detectPackageManager(); + let manualCommand: string; - switch (packageManager) { - case 'npm': - manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`; - break; - case 'yarn': - manualCommand = `yarn global add @kaitranntt/ccs@${targetTag}`; - break; - case 'pnpm': - manualCommand = `pnpm add -g @kaitranntt/ccs@${targetTag}`; - break; - case 'bun': - manualCommand = `bun add -g @kaitranntt/ccs@${targetTag}`; - break; - default: - manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`; - } - - console.log(color(` ${manualCommand}`, 'command')); - } else { - const isWindows = process.platform === 'win32'; - if (isWindows) { - console.log(color(' irm ccs.kaitran.ca/install | iex', 'command')); - } else { - console.log(color(' curl -fsSL ccs.kaitran.ca/install | bash', 'command')); - } + switch (packageManager) { + case 'npm': + manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`; + break; + case 'yarn': + manualCommand = `yarn global add @kaitranntt/ccs@${targetTag}`; + break; + case 'pnpm': + manualCommand = `pnpm add -g @kaitranntt/ccs@${targetTag}`; + break; + case 'bun': + manualCommand = `bun add -g @kaitranntt/ccs@${targetTag}`; + break; + default: + manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`; } + + console.log(color(` ${manualCommand}`, 'command')); console.log(''); process.exit(1); } @@ -300,86 +270,3 @@ async function performNpmUpdate( performUpdate(); } } - -/** - * Handle direct install beta not supported error - */ -function handleDirectBetaNotSupported(): void { - console.log(fail('--beta flag requires npm installation')); - console.log(''); - console.log('Current installation method: direct installer'); - console.log('To use beta releases, install via npm:'); - console.log(''); - console.log(color(' npm install -g @kaitranntt/ccs', 'command')); - console.log(color(' ccs update --beta', 'command')); - console.log(''); - console.log('Or continue using stable releases via direct installer.'); - console.log(''); - process.exit(1); -} - -/** - * Perform update via direct installer (curl/irm) - */ -async function performDirectUpdate(): Promise { - console.log(info('Updating via installer...')); - console.log(''); - - const isWindows = process.platform === 'win32'; - let command: string; - let args: string[]; - - if (isWindows) { - command = 'powershell.exe'; - args = [ - '-NoProfile', - '-ExecutionPolicy', - 'Bypass', - '-Command', - 'irm ccs.kaitran.ca/install | iex', - ]; - } else { - command = '/bin/bash'; - args = ['-c', 'curl -fsSL ccs.kaitran.ca/install | bash']; - } - - const child = spawn(command, args, { - stdio: 'inherit', - }); - - child.on('exit', (code) => { - if (code === 0) { - console.log(''); - console.log(ok('Update successful!')); - console.log(''); - console.log(`Run ${color('ccs --version', 'command')} to verify`); - console.log(''); - } else { - console.log(''); - console.log(fail('Update failed')); - console.log(''); - console.log('Try manually:'); - if (isWindows) { - console.log(color(' irm ccs.kaitran.ca/install | iex', 'command')); - } else { - console.log(color(' curl -fsSL ccs.kaitran.ca/install | bash', 'command')); - } - console.log(''); - } - process.exit(code || 0); - }); - - child.on('error', () => { - console.log(''); - console.log(fail('Failed to run installer')); - console.log(''); - console.log('Try manually:'); - if (isWindows) { - console.log(color(' irm ccs.kaitran.ca/install | iex', 'command')); - } else { - console.log(color(' curl -fsSL ccs.kaitran.ca/install | bash', 'command')); - } - console.log(''); - process.exit(1); - }); -} diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index de6efa48..98c72da1 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -15,6 +15,9 @@ import { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION, DEFAULT_COPILOT_CONFIG, + DEFAULT_GLOBAL_ENV, + DEFAULT_CLIPROXY_SERVER_CONFIG, + GlobalEnvConfig, } from './unified-config-types'; import { isUnifiedConfigEnabled } from './feature-flags'; @@ -170,6 +173,40 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { wait_on_limit: partial.copilot?.wait_on_limit ?? DEFAULT_COPILOT_CONFIG.wait_on_limit, model: partial.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model, }, + // Global env - injected into all non-Claude subscription profiles + global_env: { + enabled: partial.global_env?.enabled ?? true, + env: partial.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV }, + }, + // CLIProxy server config - remote/local CLIProxyAPI settings + cliproxy_server: { + remote: { + enabled: + partial.cliproxy_server?.remote?.enabled ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.enabled, + host: partial.cliproxy_server?.remote?.host ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.host, + port: partial.cliproxy_server?.remote?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.port, + protocol: + partial.cliproxy_server?.remote?.protocol ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.remote.protocol, + auth_token: + partial.cliproxy_server?.remote?.auth_token ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.remote.auth_token, + }, + fallback: { + enabled: + partial.cliproxy_server?.fallback?.enabled ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.enabled, + auto_start: + partial.cliproxy_server?.fallback?.auto_start ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.auto_start, + }, + local: { + port: partial.cliproxy_server?.local?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.local.port, + auto_start: + partial.cliproxy_server?.local?.auto_start ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.local.auto_start, + }, + }, }; } @@ -336,6 +373,28 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Global env section + if (config.global_env) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + '# Global Environment Variables: Injected into all non-Claude subscription profiles' + ); + lines.push('# These env vars disable telemetry/reporting for third-party providers.'); + lines.push('# Configure via Dashboard (`ccs config`) > Global Env tab.'); + lines.push('#'); + lines.push('# Default variables:'); + lines.push('# DISABLE_BUG_COMMAND: Disables /bug command (not supported by proxy)'); + lines.push('# DISABLE_ERROR_REPORTING: Disables error reporting to Anthropic'); + lines.push('# DISABLE_TELEMETRY: Disables usage telemetry'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump({ global_env: config.global_env }, { indent: 2, lineWidth: -1, quotingType: '"' }) + .trim() + ); + lines.push(''); + } + return lines.join('\n'); } @@ -462,3 +521,15 @@ export function getWebSearchConfig(): { gemini: config.websearch?.gemini, }; } + +/** + * Get global_env configuration. + * Returns defaults if not configured. + */ +export function getGlobalEnvConfig(): GlobalEnvConfig { + const config = loadOrCreateUnifiedConfig(); + return { + enabled: config.global_env?.enabled ?? true, + env: config.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV }, + }; +} diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index a692173c..fcffd26d 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -14,8 +14,9 @@ * Version 2 = YAML unified format * Version 3 = WebSearch config with model configuration for Gemini/OpenCode * Version 4 = Copilot API integration (GitHub Copilot proxy) + * Version 5 = Remote proxy configuration (connect to remote CLIProxyAPI) */ -export const UNIFIED_CONFIG_VERSION = 4; +export const UNIFIED_CONFIG_VERSION = 5; /** * Account configuration (formerly in profiles.json). @@ -185,6 +186,78 @@ export interface CopilotConfig { haiku_model?: string; } +/** + * Remote proxy configuration. + * Connect to a remote CLIProxyAPI instance instead of spawning local binary. + */ +export interface ProxyRemoteConfig { + /** Enable remote proxy mode (default: false = local mode) */ + enabled: boolean; + /** Remote proxy hostname or IP (empty = not configured) */ + host: string; + /** Remote proxy port (default: 8317) */ + port: number; + /** Protocol for remote connection */ + protocol: 'http' | 'https'; + /** Auth token for remote proxy (optional, sent as header) */ + auth_token: string; +} + +/** + * Fallback configuration when remote proxy is unreachable. + */ +export interface ProxyFallbackConfig { + /** Enable fallback to local proxy (default: true) */ + enabled: boolean; + /** Auto-start local proxy without prompting (default: false = prompt user) */ + auto_start: boolean; +} + +/** + * Local proxy configuration. + */ +export interface ProxyLocalConfig { + /** Local proxy port (default: 8317) */ + port: number; + /** Auto-start local binary (default: true) */ + auto_start: boolean; +} + +/** + * CLIProxy server configuration section. + * Controls whether CCS uses local or remote CLIProxyAPI instance. + */ +export interface CliproxyServerConfig { + /** Remote proxy settings */ + remote: ProxyRemoteConfig; + /** Fallback behavior when remote is unreachable */ + fallback: ProxyFallbackConfig; + /** Local proxy settings */ + local: ProxyLocalConfig; +} + +/** + * Global environment variables configuration. + * These env vars are injected into ALL non-Claude subscription profiles. + * Useful for disabling telemetry, bug commands, error reporting, etc. + */ +export interface GlobalEnvConfig { + /** Enable global env injection (default: true) */ + enabled: boolean; + /** Environment variables to inject */ + env: Record; +} + +/** + * Default global env vars for third-party profiles. + * These disable Claude Code telemetry/reporting since we're using proxy. + */ +export const DEFAULT_GLOBAL_ENV: Record = { + DISABLE_BUG_COMMAND: '1', + DISABLE_ERROR_REPORTING: '1', + DISABLE_TELEMETRY: '1', +}; + /** * WebSearch configuration. * Uses CLI tools (Gemini CLI, Grok CLI, OpenCode) for third-party profiles. @@ -220,7 +293,7 @@ export interface WebSearchConfig { * Stored in ~/.ccs/config.yaml */ export interface UnifiedConfig { - /** Config version (4 for copilot support) */ + /** Config version (5 for remote proxy support) */ version: number; /** Default profile name to use when none specified */ default?: string; @@ -234,8 +307,12 @@ export interface UnifiedConfig { preferences: PreferencesConfig; /** WebSearch configuration */ websearch?: WebSearchConfig; + /** Global environment variables for all non-Claude subscription profiles */ + global_env?: GlobalEnvConfig; /** Copilot API configuration (GitHub Copilot proxy) */ copilot?: CopilotConfig; + /** CLIProxy server configuration for remote/local mode */ + cliproxy_server?: CliproxyServerConfig; } /** @@ -265,6 +342,28 @@ export const DEFAULT_COPILOT_CONFIG: CopilotConfig = { model: 'gpt-4.1', // Free tier compatible }; +/** + * Default CLIProxy server configuration. + * Local mode by default - remote must be explicitly enabled. + */ +export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = { + remote: { + enabled: false, + host: '', + port: 8317, + protocol: 'http', + auth_token: '', + }, + fallback: { + enabled: true, + auto_start: false, + }, + local: { + port: 8317, + auto_start: true, + }, +}; + /** * Create an empty unified config with defaults. */ @@ -307,7 +406,12 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { }, }, }, + global_env: { + enabled: true, + env: { ...DEFAULT_GLOBAL_ENV }, + }, copilot: { ...DEFAULT_COPILOT_CONFIG }, + cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG }, }; } diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index 012fb385..794cf8ff 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -7,6 +7,7 @@ import { spawn } from 'child_process'; import { CopilotConfig } from '../config/unified-config-types'; +import { getGlobalEnvConfig } from '../config/unified-config-loader'; import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth'; import { isDaemonRunning, startDaemon } from './copilot-daemon'; import { ensureCopilotApi } from './copilot-package-manager'; @@ -127,9 +128,14 @@ export async function executeCopilotProfile( // Generate environment for Claude const copilotEnv = generateCopilotEnv(config); - // Merge with current environment + // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles + const globalEnvConfig = getGlobalEnvConfig(); + const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; + + // Merge with current environment (global env first, copilot overrides) const env = { ...process.env, + ...globalEnv, ...copilotEnv, }; diff --git a/src/utils/package-manager-detector.ts b/src/utils/package-manager-detector.ts index a30fb39b..ac5840a0 100644 --- a/src/utils/package-manager-detector.ts +++ b/src/utils/package-manager-detector.ts @@ -2,77 +2,13 @@ * Package Manager Detector Utilities * * Cross-platform package manager detection utilities for CCS. + * Now only supports npm-based installation (npm/yarn/pnpm/bun). */ import * as path from 'path'; import * as fs from 'fs'; import { spawnSync } from 'child_process'; -/** - * Detect installation method - */ -export function detectInstallationMethod(): 'npm' | 'direct' { - const scriptPath = process.argv[1]; - - // Method 1: Check if script is inside node_modules - if (scriptPath.includes('node_modules')) { - return 'npm'; - } - - // Method 2: Check if script is in npm global bin directory - const npmGlobalBinPatterns = [ - /\.npm\/global\/bin\//, - /\/\.nvm\/versions\/node\/[^/]+\/bin\//, - /\/usr\/local\/bin\//, - /\/usr\/bin\//, - ]; - - for (const pattern of npmGlobalBinPatterns) { - if (pattern.test(scriptPath)) { - try { - const binDir = path.dirname(scriptPath); - const nodeModulesDir = path.join(binDir, '..', 'lib', 'node_modules', '@kaitranntt', 'ccs'); - const globalModulesDir = path.join(binDir, '..', 'node_modules', '@kaitranntt', 'ccs'); - - if (fs.existsSync(nodeModulesDir) || fs.existsSync(globalModulesDir)) { - return 'npm'; - } - } catch (_err) { - // Continue checking other patterns - } - } - } - - // Method 3: Check if package.json exists in parent directory - const packageJsonPath = path.join(__dirname, '..', 'package.json'); - - if (fs.existsSync(packageJsonPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - if (pkg.name === '@kaitranntt/ccs') { - return 'npm'; - } - } catch (_err) { - // Ignore parse errors - } - } - - // Method 4: Check if script is a symlink pointing to node_modules - try { - const stats = fs.lstatSync(scriptPath); - if (stats.isSymbolicLink()) { - const targetPath = fs.readlinkSync(scriptPath); - if (targetPath.includes('node_modules') || targetPath.includes('@kaitranntt/ccs')) { - return 'npm'; - } - } - } catch (_err) { - // Continue to default - } - - return 'direct'; -} - /** * Detect which package manager was used for installation */ diff --git a/src/web-server/index.ts b/src/web-server/index.ts index e9c4ba3a..a4de7f80 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -51,6 +51,10 @@ export async function startServer(options: ServerOptions): Promise { + try { + const result = stopProxy(); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/update-check - Check for CLIProxyAPI binary updates + * Returns: { hasUpdate, currentVersion, latestVersion, fromCache } + */ +apiRoutes.get('/cliproxy/update-check', async (_req: Request, res: Response): Promise => { + try { + const result = await checkCliproxyUpdate(); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + /** * GET /api/cliproxy/models - Get available models from CLIProxyAPI * Returns: { models: CliproxyModel[], byCategory: Record, totalCount: number } @@ -1418,6 +1448,84 @@ apiRoutes.get('/cliproxy/models', async (_req: Request, res: Response): Promise< } }); +// ==================== Error Logs ==================== + +/** + * GET /api/cliproxy/error-logs - Get list of error log files + * Returns: { files: CliproxyErrorLog[] } or error if proxy not running + */ +apiRoutes.get('/cliproxy/error-logs', async (_req: Request, res: Response): Promise => { + try { + const running = await isCliproxyRunning(); + if (!running) { + res.status(503).json({ + error: 'CLIProxyAPI not running', + message: 'Start a CLIProxy session to view error logs', + }); + return; + } + + const files = await fetchCliproxyErrorLogs(); + if (files === null) { + res.status(503).json({ + error: 'Error logs unavailable', + message: 'CLIProxyAPI is running but error logs endpoint not responding', + }); + return; + } + + // Inject absolute paths into each file entry + const logsDir = path.join(getCliproxyWritablePath(), 'logs'); + const filesWithPaths = files.map((file) => ({ + ...file, + absolutePath: path.join(logsDir, file.name), + })); + + res.json({ files: filesWithPaths }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/error-logs/:name - Get content of a specific error log + * Returns: plain text log content + */ +apiRoutes.get('/cliproxy/error-logs/:name', async (req: Request, res: Response): Promise => { + const { name } = req.params; + + // Validate filename format and prevent path traversal + if ( + !name || + !name.startsWith('error-') || + !name.endsWith('.log') || + name.includes('..') || + name.includes('/') || + name.includes('\\') + ) { + res.status(400).json({ error: 'Invalid error log filename' }); + return; + } + + try { + const running = await isCliproxyRunning(); + if (!running) { + res.status(503).json({ error: 'CLIProxyAPI not running' }); + return; + } + + const content = await fetchCliproxyErrorLogContent(name); + if (content === null) { + res.status(404).json({ error: 'Error log not found' }); + return; + } + + res.type('text/plain').send(content); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + // ============================================ // OpenAI Compatibility Layer Routes // ============================================ @@ -1708,7 +1816,7 @@ import { getInstalledVersion as getCopilotInstalledVersion, } from '../copilot'; import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types'; -import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; +import { loadOrCreateUnifiedConfig, getGlobalEnvConfig } from '../config/unified-config-loader'; /** * GET /api/copilot/status - Get Copilot status (auth + daemon + install info) @@ -2000,3 +2108,50 @@ apiRoutes.put('/copilot/settings/raw', (req: Request, res: Response): void => { res.status(500).json({ error: (error as Error).message }); } }); + +// ==================== Global Environment Variables ==================== + +/** + * GET /api/global-env - Get global environment variables configuration + * Returns the global_env section from config.yaml + */ +apiRoutes.get('/global-env', (_req: Request, res: Response): void => { + try { + const config = getGlobalEnvConfig(); + res.json(config); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/global-env - Update global environment variables configuration + * Updates the global_env section in config.yaml + */ +apiRoutes.put('/global-env', (req: Request, res: Response): void => { + try { + const { enabled, env } = req.body; + const config = loadOrCreateUnifiedConfig(); + + // Validate env is an object with string values + if (env !== undefined && typeof env === 'object' && env !== null) { + for (const [key, value] of Object.entries(env)) { + if (typeof value !== 'string') { + res.status(400).json({ error: `Invalid value for ${key}: must be a string` }); + return; + } + } + } + + // Update global_env section + config.global_env = { + enabled: enabled ?? config.global_env?.enabled ?? true, + env: env ?? config.global_env?.env ?? {}, + }; + + saveUnifiedConfig(config); + res.json({ success: true, config: config.global_env }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); diff --git a/src/web-server/routes/proxy-routes.ts b/src/web-server/routes/proxy-routes.ts new file mode 100644 index 00000000..2a9b1d9a --- /dev/null +++ b/src/web-server/routes/proxy-routes.ts @@ -0,0 +1,96 @@ +/** + * CLIProxy Server Routes - API endpoints for proxy configuration + * + * Provides REST endpoints for managing CLIProxyAPI connection settings: + * - GET /api/cliproxy-server - Get proxy configuration + * - PUT /api/cliproxy-server - Update proxy configuration + * - POST /api/cliproxy-server/test - Test remote connection + */ + +import { Router, Request, Response } from 'express'; +import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader'; +import { testConnection } from '../../cliproxy/remote-proxy-client'; +import { + DEFAULT_CLIPROXY_SERVER_CONFIG, + CliproxyServerConfig, +} from '../../config/unified-config-types'; + +const router = Router(); + +/** + * GET /api/cliproxy-server - Get proxy configuration + */ +router.get('/', async (_req: Request, res: Response) => { + try { + const config = await loadOrCreateUnifiedConfig(); + res.json(config.cliproxy_server || DEFAULT_CLIPROXY_SERVER_CONFIG); + } catch (error) { + console.error('[cliproxy-server-routes] Failed to load proxy config:', error); + res.status(500).json({ error: 'Failed to load proxy config' }); + } +}); + +/** + * PUT /api/cliproxy-server - Update proxy configuration + */ +router.put('/', async (req: Request, res: Response) => { + try { + const config = await loadOrCreateUnifiedConfig(); + const updates = req.body as Partial; + + // Deep merge with defaults and current config + config.cliproxy_server = { + remote: { + ...DEFAULT_CLIPROXY_SERVER_CONFIG.remote, + ...config.cliproxy_server?.remote, + ...updates.remote, + }, + fallback: { + ...DEFAULT_CLIPROXY_SERVER_CONFIG.fallback, + ...config.cliproxy_server?.fallback, + ...updates.fallback, + }, + local: { + ...DEFAULT_CLIPROXY_SERVER_CONFIG.local, + ...config.cliproxy_server?.local, + ...updates.local, + }, + }; + + await saveUnifiedConfig(config); + res.json(config.cliproxy_server); + } catch (error) { + console.error('[cliproxy-server-routes] Failed to save proxy config:', error); + res.status(500).json({ error: 'Failed to save proxy config' }); + } +}); + +/** + * POST /api/cliproxy-server/test - Test remote proxy connection + */ +router.post('/test', async (req: Request, res: Response) => { + try { + const { host, port, protocol, authToken, allowSelfSigned } = req.body; + + if (!host || !port) { + res.status(400).json({ error: 'Host and port are required' }); + return; + } + + const status = await testConnection({ + host, + port: typeof port === 'number' ? port : parseInt(port, 10), + protocol: protocol || 'http', + authToken, + allowSelfSigned: allowSelfSigned || false, + timeout: 5000, + }); + + res.json(status); + } catch (error) { + console.error('[cliproxy-server-routes] Failed to test connection:', error); + res.status(500).json({ error: 'Failed to test connection' }); + } +}); + +export default router; diff --git a/tests/unit/cliproxy/proxy-config-resolver.test.js b/tests/unit/cliproxy/proxy-config-resolver.test.js new file mode 100644 index 00000000..2a7d0851 --- /dev/null +++ b/tests/unit/cliproxy/proxy-config-resolver.test.js @@ -0,0 +1,274 @@ +/** + * Unit tests for proxy-config-resolver module + */ +const { describe, it, expect, beforeEach, afterEach } = require('bun:test'); + +// Import from compiled dist +const { + parseProxyFlags, + getProxyEnvVars, + resolveProxyConfig, + hasProxyFlags, + PROXY_CLI_FLAGS, + PROXY_ENV_VARS, +} = require('../../../dist/cliproxy/proxy-config-resolver'); + +describe('proxy-config-resolver', () => { + describe('PROXY_CLI_FLAGS', () => { + it('should define all expected proxy flags', () => { + expect(PROXY_CLI_FLAGS).toContain('--proxy-host'); + expect(PROXY_CLI_FLAGS).toContain('--proxy-port'); + expect(PROXY_CLI_FLAGS).toContain('--proxy-protocol'); + expect(PROXY_CLI_FLAGS).toContain('--proxy-auth-token'); + expect(PROXY_CLI_FLAGS).toContain('--local-proxy'); + expect(PROXY_CLI_FLAGS).toContain('--remote-only'); + }); + }); + + describe('PROXY_ENV_VARS', () => { + it('should define all expected environment variable names', () => { + expect(PROXY_ENV_VARS.host).toBe('CCS_PROXY_HOST'); + expect(PROXY_ENV_VARS.port).toBe('CCS_PROXY_PORT'); + expect(PROXY_ENV_VARS.protocol).toBe('CCS_PROXY_PROTOCOL'); + expect(PROXY_ENV_VARS.authToken).toBe('CCS_PROXY_AUTH_TOKEN'); + expect(PROXY_ENV_VARS.fallbackEnabled).toBe('CCS_PROXY_FALLBACK_ENABLED'); + }); + }); + + describe('parseProxyFlags', () => { + it('should parse --proxy-host flag', () => { + const { flags, remainingArgs } = parseProxyFlags(['--proxy-host', '192.168.1.100']); + expect(flags.host).toBe('192.168.1.100'); + expect(remainingArgs).toEqual([]); + }); + + it('should parse --proxy-port flag', () => { + const { flags, remainingArgs } = parseProxyFlags(['--proxy-port', '9000']); + expect(flags.port).toBe(9000); + expect(remainingArgs).toEqual([]); + }); + + it('should parse --proxy-protocol flag', () => { + const { flags } = parseProxyFlags(['--proxy-protocol', 'https']); + expect(flags.protocol).toBe('https'); + }); + + it('should parse --proxy-auth-token flag', () => { + const { flags } = parseProxyFlags(['--proxy-auth-token', 'secret123']); + expect(flags.authToken).toBe('secret123'); + }); + + it('should parse --local-proxy boolean flag', () => { + const { flags } = parseProxyFlags(['--local-proxy']); + expect(flags.localProxy).toBe(true); + }); + + it('should parse --remote-only boolean flag', () => { + const { flags } = parseProxyFlags(['--remote-only']); + expect(flags.remoteOnly).toBe(true); + }); + + it('should preserve non-proxy args in remainingArgs', () => { + const { flags, remainingArgs } = parseProxyFlags([ + '--verbose', + '--proxy-host', + 'localhost', + '--some-other-flag', + ]); + expect(flags.host).toBe('localhost'); + expect(remainingArgs).toEqual(['--verbose', '--some-other-flag']); + }); + + it('should handle mixed proxy and non-proxy args', () => { + const { flags, remainingArgs } = parseProxyFlags([ + 'arg1', + '--proxy-port', + '8080', + 'arg2', + '--local-proxy', + 'arg3', + ]); + expect(flags.port).toBe(8080); + expect(flags.localProxy).toBe(true); + expect(remainingArgs).toEqual(['arg1', 'arg2', 'arg3']); + }); + + it('should ignore invalid port values', () => { + const { flags } = parseProxyFlags(['--proxy-port', 'invalid']); + expect(flags.port).toBeUndefined(); + }); + + it('should ignore out-of-range port values', () => { + const { flags: flags1 } = parseProxyFlags(['--proxy-port', '0']); + expect(flags1.port).toBeUndefined(); + + const { flags: flags2 } = parseProxyFlags(['--proxy-port', '70000']); + expect(flags2.port).toBeUndefined(); + }); + + it('should normalize protocol to lowercase', () => { + const { flags } = parseProxyFlags(['--proxy-protocol', 'HTTPS']); + expect(flags.protocol).toBe('https'); + }); + + it('should ignore invalid protocol values', () => { + const { flags } = parseProxyFlags(['--proxy-protocol', 'ftp']); + expect(flags.protocol).toBeUndefined(); + }); + }); + + describe('getProxyEnvVars', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Clear proxy env vars + delete process.env.CCS_PROXY_HOST; + delete process.env.CCS_PROXY_PORT; + delete process.env.CCS_PROXY_PROTOCOL; + delete process.env.CCS_PROXY_AUTH_TOKEN; + delete process.env.CCS_PROXY_FALLBACK_ENABLED; + }); + + afterEach(() => { + // Restore original env + Object.keys(process.env).forEach((key) => { + if (key.startsWith('CCS_PROXY_')) { + delete process.env[key]; + } + }); + Object.assign(process.env, originalEnv); + }); + + it('should return empty config when no env vars set', () => { + const config = getProxyEnvVars(); + expect(config.host).toBeUndefined(); + expect(config.port).toBeUndefined(); + expect(config.protocol).toBeUndefined(); + expect(config.authToken).toBeUndefined(); + expect(config.fallbackEnabled).toBeUndefined(); + }); + + it('should read CCS_PROXY_HOST', () => { + process.env.CCS_PROXY_HOST = 'remote.example.com'; + const config = getProxyEnvVars(); + expect(config.host).toBe('remote.example.com'); + }); + + it('should read and parse CCS_PROXY_PORT', () => { + process.env.CCS_PROXY_PORT = '9000'; + const config = getProxyEnvVars(); + expect(config.port).toBe(9000); + }); + + it('should read CCS_PROXY_PROTOCOL', () => { + process.env.CCS_PROXY_PROTOCOL = 'https'; + const config = getProxyEnvVars(); + expect(config.protocol).toBe('https'); + }); + + it('should read CCS_PROXY_AUTH_TOKEN', () => { + process.env.CCS_PROXY_AUTH_TOKEN = 'my-secret-token'; + const config = getProxyEnvVars(); + expect(config.authToken).toBe('my-secret-token'); + }); + + it('should parse CCS_PROXY_FALLBACK_ENABLED as true', () => { + process.env.CCS_PROXY_FALLBACK_ENABLED = '1'; + expect(getProxyEnvVars().fallbackEnabled).toBe(true); + + process.env.CCS_PROXY_FALLBACK_ENABLED = 'true'; + expect(getProxyEnvVars().fallbackEnabled).toBe(true); + + process.env.CCS_PROXY_FALLBACK_ENABLED = 'yes'; + expect(getProxyEnvVars().fallbackEnabled).toBe(true); + }); + + it('should parse CCS_PROXY_FALLBACK_ENABLED as false', () => { + process.env.CCS_PROXY_FALLBACK_ENABLED = '0'; + expect(getProxyEnvVars().fallbackEnabled).toBe(false); + + process.env.CCS_PROXY_FALLBACK_ENABLED = 'false'; + expect(getProxyEnvVars().fallbackEnabled).toBe(false); + + process.env.CCS_PROXY_FALLBACK_ENABLED = 'no'; + expect(getProxyEnvVars().fallbackEnabled).toBe(false); + }); + }); + + describe('resolveProxyConfig', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.CCS_PROXY_HOST; + delete process.env.CCS_PROXY_PORT; + delete process.env.CCS_PROXY_PROTOCOL; + delete process.env.CCS_PROXY_AUTH_TOKEN; + delete process.env.CCS_PROXY_FALLBACK_ENABLED; + }); + + afterEach(() => { + Object.keys(process.env).forEach((key) => { + if (key.startsWith('CCS_PROXY_')) { + delete process.env[key]; + } + }); + Object.assign(process.env, originalEnv); + }); + + it('should return local mode by default', () => { + const { config } = resolveProxyConfig([]); + expect(config.mode).toBe('local'); + expect(config.port).toBe(8317); // Default CLIProxy port + expect(config.fallbackEnabled).toBe(true); + }); + + it('should enable remote mode when --proxy-host is provided', () => { + const { config } = resolveProxyConfig(['--proxy-host', '192.168.1.100']); + expect(config.mode).toBe('remote'); + expect(config.host).toBe('192.168.1.100'); + }); + + it('should enable remote mode when CCS_PROXY_HOST env is set', () => { + process.env.CCS_PROXY_HOST = 'remote.example.com'; + const { config } = resolveProxyConfig([]); + expect(config.mode).toBe('remote'); + expect(config.host).toBe('remote.example.com'); + }); + + it('should prioritize CLI flags over ENV vars', () => { + process.env.CCS_PROXY_HOST = 'env-host'; + process.env.CCS_PROXY_PORT = '9000'; + const { config } = resolveProxyConfig(['--proxy-host', 'cli-host', '--proxy-port', '8080']); + expect(config.host).toBe('cli-host'); + expect(config.port).toBe(8080); + }); + + it('should force local mode with --local-proxy', () => { + process.env.CCS_PROXY_HOST = 'remote.example.com'; + const { config } = resolveProxyConfig(['--local-proxy']); + expect(config.mode).toBe('local'); + expect(config.forceLocal).toBe(true); + }); + + it('should set remoteOnly and disable fallback with --remote-only', () => { + const { config } = resolveProxyConfig(['--proxy-host', 'remote', '--remote-only']); + expect(config.remoteOnly).toBe(true); + expect(config.fallbackEnabled).toBe(false); + }); + }); + + describe('hasProxyFlags', () => { + it('should return true when proxy flags are present', () => { + expect(hasProxyFlags(['--proxy-host', 'localhost'])).toBe(true); + expect(hasProxyFlags(['--proxy-port', '8080'])).toBe(true); + expect(hasProxyFlags(['--local-proxy'])).toBe(true); + expect(hasProxyFlags(['--remote-only'])).toBe(true); + }); + + it('should return false when no proxy flags are present', () => { + expect(hasProxyFlags([])).toBe(false); + expect(hasProxyFlags(['--verbose', '--help'])).toBe(false); + expect(hasProxyFlags(['gemini', 'some-task'])).toBe(false); + }); + }); +}); diff --git a/tests/unit/cliproxy/remote-proxy-client.test.ts b/tests/unit/cliproxy/remote-proxy-client.test.ts new file mode 100644 index 00000000..61022c11 --- /dev/null +++ b/tests/unit/cliproxy/remote-proxy-client.test.ts @@ -0,0 +1,128 @@ +/** + * Unit tests for remote-proxy-client module + */ +import { describe, it, expect } from 'bun:test'; +import type { RemoteProxyClientConfig, RemoteProxyStatus } from '../../../src/cliproxy/remote-proxy-client'; + +// We test the module's type exports and error handling logic +// Actual HTTP calls are not mocked in this unit test - use integration tests for that + +describe('remote-proxy-client', () => { + describe('type exports', () => { + it('should export RemoteProxyClientConfig interface', () => { + // Type-level test - ensure the interface shape is correct + const config: RemoteProxyClientConfig = { + host: 'localhost', + port: 8317, + protocol: 'http', + authToken: 'test-token', + timeout: 2000, + allowSelfSigned: false, + }; + expect(config.host).toBe('localhost'); + expect(config.port).toBe(8317); + expect(config.protocol).toBe('http'); + }); + + it('should export RemoteProxyStatus interface', () => { + // Success case + const successStatus: RemoteProxyStatus = { + reachable: true, + latencyMs: 50, + }; + expect(successStatus.reachable).toBe(true); + expect(successStatus.latencyMs).toBe(50); + + // Error case + const errorStatus: RemoteProxyStatus = { + reachable: false, + error: 'Connection refused', + errorCode: 'CONNECTION_REFUSED', + }; + expect(errorStatus.reachable).toBe(false); + expect(errorStatus.error).toBe('Connection refused'); + expect(errorStatus.errorCode).toBe('CONNECTION_REFUSED'); + }); + }); + + describe('RemoteProxyErrorCode', () => { + it('should define expected error codes', () => { + const validCodes = ['CONNECTION_REFUSED', 'TIMEOUT', 'AUTH_FAILED', 'UNKNOWN']; + + // Type-level test - ensure error codes can be used + const status1: RemoteProxyStatus = { reachable: false, errorCode: 'CONNECTION_REFUSED' }; + const status2: RemoteProxyStatus = { reachable: false, errorCode: 'TIMEOUT' }; + const status3: RemoteProxyStatus = { reachable: false, errorCode: 'AUTH_FAILED' }; + const status4: RemoteProxyStatus = { reachable: false, errorCode: 'UNKNOWN' }; + + expect(validCodes).toContain(status1.errorCode); + expect(validCodes).toContain(status2.errorCode); + expect(validCodes).toContain(status3.errorCode); + expect(validCodes).toContain(status4.errorCode); + }); + }); + + describe('config validation', () => { + it('should require host and port', () => { + const minimalConfig: RemoteProxyClientConfig = { + host: '127.0.0.1', + port: 8317, + protocol: 'http', + }; + expect(minimalConfig.host).toBeDefined(); + expect(minimalConfig.port).toBeDefined(); + expect(minimalConfig.protocol).toBeDefined(); + }); + + it('should allow optional fields', () => { + const config: RemoteProxyClientConfig = { + host: '127.0.0.1', + port: 8317, + protocol: 'https', + authToken: 'secret', + timeout: 5000, + allowSelfSigned: true, + }; + expect(config.authToken).toBe('secret'); + expect(config.timeout).toBe(5000); + expect(config.allowSelfSigned).toBe(true); + }); + + it('should accept http and https protocols', () => { + const httpConfig: RemoteProxyClientConfig = { + host: 'localhost', + port: 8317, + protocol: 'http', + }; + const httpsConfig: RemoteProxyClientConfig = { + host: 'localhost', + port: 8317, + protocol: 'https', + }; + expect(httpConfig.protocol).toBe('http'); + expect(httpsConfig.protocol).toBe('https'); + }); + }); + + describe('health check URL construction', () => { + it('should construct correct health check URL pattern', () => { + const config: RemoteProxyClientConfig = { + host: '192.168.1.100', + port: 8317, + protocol: 'http', + }; + const expectedUrl = `${config.protocol}://${config.host}:${config.port}/health`; + expect(expectedUrl).toBe('http://192.168.1.100:8317/health'); + }); + + it('should construct HTTPS URL when protocol is https', () => { + const config: RemoteProxyClientConfig = { + host: 'secure.example.com', + port: 443, + protocol: 'https', + }; + const expectedUrl = `${config.protocol}://${config.host}:${config.port}/health`; + expect(expectedUrl).toBe('https://secure.example.com:443/health'); + }); + }); +}); diff --git a/tests/unit/commands/update-command-beta-channel.test.js b/tests/unit/commands/update-command-beta-channel.test.js index 52a84a6b..3f9ccbd1 100644 --- a/tests/unit/commands/update-command-beta-channel.test.js +++ b/tests/unit/commands/update-command-beta-channel.test.js @@ -118,9 +118,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( describe('Beta stability warning display', function () { it('should show beta warning when installing from dev channel', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; // Mock update checker to return update available @@ -166,7 +164,6 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( assert(returnStable, 'should show return to stable instruction'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; updateCheckerModule.checkForUpdates = originalCheckForUpdates; require('child_process').spawn = originalSpawn; @@ -175,9 +172,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( it('should NOT show beta warning for stable channel', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; // Mock update checker to return update available @@ -204,7 +199,6 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( assert(!unstableWarning, 'should not show production warning for stable channel'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; updateCheckerModule.checkForUpdates = originalCheckForUpdates; } @@ -212,9 +206,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( it('should show beta warning even with force flag', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -228,7 +220,6 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( assert(betaWarning, 'should show beta warning even with force'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); @@ -237,9 +228,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( describe('handleCheckFailed with targetTag parameter', function () { it('should show manual update command with dev tag for npm install', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -265,9 +254,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( it('should show manual update command with latest tag for stable', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -304,9 +291,7 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( consoleOutput = []; // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => name; try { @@ -330,96 +315,13 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( assert(manualCommand, `should show manual ${name} command with dev tag`); // Restore functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; }); }); - - it('should show direct install commands when npm detection fails', function () { - // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'direct'; - packageManagerDetectorModule.detectPackageManager = () => 'npm'; - - try { - // Mock checkForUpdates to return failed - const originalCheckForUpdates = updateCheckerModule.checkForUpdates; - updateCheckerModule.checkForUpdates = async () => ({ - status: 'check_failed', - message: 'Failed to check for updates' - }); - - // Call with beta: false (beta not supported for direct) - updateCommandModule.handleUpdateCommand({ beta: false }); - } catch (e) { - // Expected to exit - } - - // Should show direct install commands - if (process.platform === 'win32') { - const powershellCmd = consoleOutput.find(output => - output[0] && output[0].includes('irm ccs.kaitran.ca/install | iex') - ); - assert(powershellCmd, 'should show PowerShell command for Windows'); - } else { - const curlCmd = consoleOutput.find(output => - output[0] && output[0].includes('curl -fsSL ccs.kaitran.ca/install | bash') - ); - assert(curlCmd, 'should show curl command for Unix'); - } - }); - - it('should show beta not supported message for direct install with beta', function () { - // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - packageManagerDetectorModule.detectInstallationMethod = () => 'direct'; - - try { - // Mock checkForUpdates to return beta not supported - const originalCheckForUpdates = updateCheckerModule.checkForUpdates; - updateCheckerModule.checkForUpdates = async () => ({ - status: 'check_failed', - reason: 'beta_not_supported', - message: '--beta requires npm installation method' - }); - - // Call with beta: true - updateCommandModule.handleUpdateCommand({ beta: true }); - } catch (e) { - // Expected to exit - } - - // Should show beta not supported message - const betaError = consoleOutput.find(output => - output[0] && output[0].includes('[X] --beta requires npm installation') - ); - assert(betaError, 'should show beta not supported error'); - - const currentMethod = consoleOutput.find(output => - output[0] && output[0].includes('Current installation method: direct installer') - ); - assert(currentMethod, 'should show current installation method'); - - // Should show npm install instructions - const npmInstall = consoleOutput.find(output => - output[0] && output[0].includes('npm install -g @kaitranntt/ccs') - ); - assert(npmInstall, 'should show npm install instructions'); - - const ccsUpdateBeta = consoleOutput.find(output => - output[0] && output[0].includes('ccs update --beta') - ); - assert(ccsUpdateBeta, 'should show ccs update --beta instruction'); - }); }); describe('Error handling', function () { it('should handle checkForUpdates throwing error', function () { - // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; - try { // Mock checkForUpdates to throw error const originalCheckForUpdates = updateCheckerModule.checkForUpdates; @@ -435,10 +337,6 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( }); it('should exit with error code 1 when check fails', function () { - // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; - try { // Mock checkForUpdates to return failed const originalCheckForUpdates = updateCheckerModule.checkForUpdates; @@ -458,10 +356,6 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( describe('Integration with update checker', function () { it('should pass correct targetTag to checkForUpdates', function () { - // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; - // Track calls to checkForUpdates let checkForUpdatesCalls = []; const originalCheckForUpdates = updateCheckerModule.checkForUpdates; @@ -480,16 +374,11 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( assert.strictEqual(devCall.installMethod, 'npm'); } finally { // Restore function - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; updateCheckerModule.checkForUpdates = originalCheckForUpdates; } }); it('should pass force parameter correctly', function () { - // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; - // Track calls to checkForUpdates let checkForUpdatesCalls = []; const originalCheckForUpdates = updateCheckerModule.checkForUpdates; @@ -507,9 +396,8 @@ describe.skip('Update Command Beta Channel Implementation (Phase 3)', function ( assert.strictEqual(checkForUpdatesCalls[0].force, true, 'should pass force parameter'); } finally { // Restore function - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; updateCheckerModule.checkForUpdates = originalCheckForUpdates; } }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/commands/update-command-force-reinstall.test.js b/tests/unit/commands/update-command-force-reinstall.test.js index d12a9513..aa85a57d 100644 --- a/tests/unit/commands/update-command-force-reinstall.test.js +++ b/tests/unit/commands/update-command-force-reinstall.test.js @@ -6,7 +6,6 @@ * - Skip update check when force is true * - Target tag calculation (latest vs dev) based on beta flag * - performNpmUpdate function with targetTag parameter - * - handleDirectBetaNotSupported function for direct installs * - Success messages showing "Reinstall" vs "Update" * * NOTE: These tests are currently skipped because they require proper mocking @@ -113,9 +112,7 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct describe('Target tag calculation based on beta flag', function () { it('should set targetTag to "latest" when beta flag is false', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -130,16 +127,13 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(latestCall, 'should install latest tag when beta is false'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); it('should set targetTag to "dev" when beta flag is true', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -154,7 +148,6 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(devCall, 'should install dev tag when beta is true'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); @@ -162,10 +155,6 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct describe('Force flag behavior', function () { it('should show force reinstall message when force is true', function () { - // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; - try { // Call with force: true updateCommandModule.handleUpdateCommand({ force: true, beta: false }); @@ -176,16 +165,13 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct ); assert(forceMessage, 'should show force reinstall message'); } finally { - // Restore original function - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; + // No cleanup needed } }); it('should bypass update check when force is true', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -201,7 +187,6 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(npmCall.args.includes('install'), 'should call install command'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); @@ -210,9 +195,7 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct describe('Package manager tag syntax', function () { it('should use correct tag syntax for npm', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -226,16 +209,13 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(npmCall.args.includes('-g'), 'should use global flag for npm'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); it('should use correct tag syntax for yarn', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'yarn'; try { @@ -249,16 +229,13 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(yarnCall.args.includes('global'), 'should use global flag for yarn'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); it('should use correct tag syntax for pnpm', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'pnpm'; try { @@ -272,16 +249,13 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(pnpmCall.args.includes('-g'), 'should use global flag for pnpm'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); it('should use correct tag syntax for bun', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'bun'; try { @@ -295,80 +269,15 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(bunCall.args.includes('-g'), 'should use global flag for bun'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); }); - describe('Direct install beta not supported', function () { - it('should show error for direct install with --beta', function () { - // Mock installation method detection as direct - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - packageManagerDetectorModule.detectInstallationMethod = () => 'direct'; - - try { - // Call with force: true, beta: true - updateCommandModule.handleUpdateCommand({ force: true, beta: true }); - - // Should show beta not supported error - const betaError = consoleOutput.find(output => - output[0] && output[0].includes('--beta flag requires npm installation') - ); - assert(betaError, 'should show beta not supported error'); - - const directInstallMsg = consoleOutput.find(output => - output[0] && output[0].includes('Current installation method: direct installer') - ); - assert(directInstallMsg, 'should show direct installer message'); - - // Should exit with error code - assert(processExitCalls.length > 0, 'should call process.exit'); - assert(processExitCalls[0] === 1, 'should exit with error code 1'); - } finally { - // Restore original function - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; - } - }); - - it('should allow force reinstall with direct install when beta is false', function () { - // Mock installation method detection as direct - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; - packageManagerDetectorModule.detectInstallationMethod = () => 'direct'; - - try { - // Call with force: true, beta: false - updateCommandModule.handleUpdateCommand({ force: true, beta: false }); - - // Should NOT show beta error - const betaError = consoleOutput.find(output => - output[0] && output[0].includes('--beta flag requires npm installation') - ); - assert(!betaError, 'should not show beta error when beta is false'); - - // Should call spawn for direct update - assert(spawnCalls.length > 0, 'should call spawn for direct update'); - - // Should call curl or powershell - const directUpdateCall = spawnCalls[0]; - if (process.platform === 'win32') { - assert(directUpdateCall.command === 'powershell.exe', 'should call powershell on Windows'); - } else { - assert(directUpdateCall.command === '/bin/bash', 'should call bash on Unix'); - } - } finally { - // Restore original function - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; - } - }); - }); - describe('Success messages', function () { it('should show "Reinstalling" message when force is true', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -382,7 +291,6 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(reinstallingMsg, 'should show reinstalling message'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); @@ -391,9 +299,7 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct describe('Combined force and beta behavior', function () { it('should handle force with beta for npm install', function () { // Mock package manager detection - const originalDetectInstallationMethod = packageManagerDetectorModule.detectInstallationMethod; const originalDetectPackageManager = packageManagerDetectorModule.detectPackageManager; - packageManagerDetectorModule.detectInstallationMethod = () => 'npm'; packageManagerDetectorModule.detectPackageManager = () => 'npm'; try { @@ -412,9 +318,8 @@ describe.skip('Update Command - Force Reinstall Implementation (Phase 2)', funct assert(forceMessage, 'should show force reinstall from dev channel message'); } finally { // Restore original functions - packageManagerDetectorModule.detectInstallationMethod = originalDetectInstallationMethod; packageManagerDetectorModule.detectPackageManager = originalDetectPackageManager; } }); }); -}); \ No newline at end of file +}); diff --git a/ui/src/components/cliproxy/provider-editor.tsx b/ui/src/components/cliproxy/provider-editor.tsx index 98b2f5bf..0fd9632e 100644 --- a/ui/src/components/cliproxy/provider-editor.tsx +++ b/ui/src/components/cliproxy/provider-editor.tsx @@ -57,6 +57,7 @@ import { useDeletePreset, } from '@/hooks/use-cliproxy'; import { cn } from '@/lib/utils'; +import { GlobalEnvIndicator } from '@/components/global-env-indicator'; import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; // Lazy load CodeEditor @@ -543,7 +544,7 @@ export function ProviderEditor({ Invalid JSON syntax )} -
+
+ {/* Global Env Indicator */} +
+
+ +
+
); diff --git a/ui/src/components/copilot/copilot-config-form.tsx b/ui/src/components/copilot/copilot-config-form.tsx index 80cce9db..2311c6b2 100644 --- a/ui/src/components/copilot/copilot-config-form.tsx +++ b/ui/src/components/copilot/copilot-config-form.tsx @@ -31,6 +31,7 @@ import { useCopilot, type CopilotModel, type CopilotPlanTier } from '@/hooks/use import { Loader2, Save, Code2, X, Info, RefreshCw, Sparkles, Zap, Check } from 'lucide-react'; import { toast } from 'sonner'; import { ConfirmDialog } from '@/components/confirm-dialog'; +import { GlobalEnvIndicator } from '@/components/global-env-indicator'; // Lazy load CodeEditor const CodeEditor = lazy(() => @@ -726,7 +727,7 @@ export function CopilotConfigForm() { Invalid JSON syntax )} -
+
+ {/* Global Env Indicator */} +
+
+ +
+
); diff --git a/ui/src/components/error-logs-monitor.tsx b/ui/src/components/error-logs-monitor.tsx new file mode 100644 index 00000000..eb704397 --- /dev/null +++ b/ui/src/components/error-logs-monitor.tsx @@ -0,0 +1,617 @@ +/** + * Error Logs Monitor Component + * + * Displays CLIProxyAPI error logs with master-detail split view. + * ETL: Parses raw logs into structured data for rich display. + * - Left panel: Log list with status code, provider, endpoint, relative time + * - Right panel: Tabbed view (Overview, Headers, Request, Response, Raw) + */ + +import { useState, useMemo, useRef, useEffect } from 'react'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { useCliproxyErrorLogs, useCliproxyErrorLogContent } from '@/hooks/use-cliproxy-stats'; +import { useCliproxyStatus } from '@/hooks/use-cliproxy-stats'; +import { cn } from '@/lib/utils'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { ProviderIcon } from '@/components/provider-icon'; +import { CopyButton } from '@/components/ui/copy-button'; +import { + AlertTriangle, + FileWarning, + Clock, + FileText, + Terminal, + Info, + Code, + ArrowUpRight, + ArrowDownLeft, + GripVertical, + GripHorizontal, +} from 'lucide-react'; +import { + parseErrorLog, + parseFilename, + formatRelativeTime, + formatBytes, + getStatusColor, + getErrorTypeLabel, + type ParsedErrorLog, +} from '@/lib/error-log-parser'; + +type TabType = 'overview' | 'headers' | 'request' | 'response' | 'raw'; + +/** Tab button component */ +function TabButton({ + active, + onClick, + children, + icon: Icon, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; + icon?: React.ComponentType<{ className?: string }>; +}) { + return ( + + ); +} + +/** Status badge component */ +function StatusBadge({ code }: { code: number }) { + const colorClass = getStatusColor(code); + return ( + + {code} + + ); +} + +/** Overview tab content */ +function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) { + return ( +
+ {/* Status row */} +
+ + {parsed.statusText} + + {getErrorTypeLabel(parsed.errorType)} + +
+ + {/* Key metrics grid */} +
+
+
Method
+
{parsed.method || 'N/A'}
+
+
+
Provider
+
{parsed.provider || 'N/A'}
+
+
+
Version
+
{parsed.version || 'N/A'}
+
+
+
Endpoint
+
+ {parsed.endpoint || 'N/A'} +
+
+
+ + {/* URL */} +
+
URL
+
+ {parsed.url || 'N/A'} +
+
+ + {/* Timestamp */} +
+
Timestamp
+
{parsed.timestamp || 'N/A'}
+
+ + {/* Suggestion based on error type */} + {parsed.errorType !== 'unknown' && ( +
+ +
+ {parsed.errorType === 'rate_limit' && + 'Rate limited. Consider using multiple accounts or reducing request frequency.'} + {parsed.errorType === 'auth' && + 'Authentication failed. Check credentials or re-authenticate with the provider.'} + {parsed.errorType === 'not_found' && + 'Endpoint not found. This endpoint may not exist on this provider.'} + {parsed.errorType === 'server' && + 'Server error from upstream. Retry or check provider status.'} + {parsed.errorType === 'timeout' && + 'Request timed out. Check network or increase timeout settings.'} +
+
+ )} +
+ ); +} + +/** Headers tab content */ +function HeadersTab({ headers }: { headers: Record }) { + const entries = Object.entries(headers); + if (entries.length === 0) { + return
No headers available
; + } + + return ( + +
+ {entries.map(([key, value]) => ( +
+ {key}: + {value} +
+ ))} +
+
+ ); +} + +/** JSON/Body tab content */ +function BodyTab({ content, label }: { content: string; label: string }) { + if (!content || content.trim() === '') { + return
No {label.toLowerCase()} body
; + } + + // Try to format as JSON + let formatted = content; + let isJson = false; + try { + const parsed = JSON.parse(content); + formatted = JSON.stringify(parsed, null, 2); + isJson = true; + } catch { + // Not JSON, use as-is + } + + return ( + +
+        {formatted}
+      
+
+ ); +} + +/** Raw tab content */ +function RawTab({ content }: { content: string }) { + return ( + +
+        {content}
+      
+
+ ); +} + +/** Log content panel with tabs */ +function LogContentPanel({ name, absolutePath }: { name: string | null; absolutePath?: string }) { + const [activeTab, setActiveTab] = useState('overview'); + const { data: content, isLoading, error } = useCliproxyErrorLogContent(name); + + // Parse log content + const parsed = useMemo(() => { + if (!content) return null; + return parseErrorLog(content); + }, [content]); + + // No log selected + if (!name) { + return ( +
+
+ +

Select a log to view details

+
+
+ ); + } + + // Loading state + if (isLoading) { + return ( +
+ + + + +
+ ); + } + + // Error or no content + if (error || !content || !parsed) { + return ( +
+

Failed to load log content

+
+ ); + } + + return ( +
+ {/* Header with status */} +
+
+ + + {parsed.provider}/{parsed.endpoint || 'unknown'} + + {/* Copy Absolute Path Button */} + {name && ( + + )} +
+
+ {/* Copy Raw Content Button */} + {content && ( + + )} + + {parsed.method} + +
+
+ + {/* Tabs */} +
+ setActiveTab('overview')} + icon={Info} + > + Overview + + setActiveTab('headers')} + icon={Code} + > + Headers + + setActiveTab('request')} + icon={ArrowUpRight} + > + Request + + setActiveTab('response')} + icon={ArrowDownLeft} + > + Response + + setActiveTab('raw')} icon={FileText}> + Raw + +
+ + {/* Tab content */} +
+ {activeTab === 'overview' && } + {activeTab === 'headers' && } + {activeTab === 'request' && } + {activeTab === 'response' && } + {activeTab === 'raw' && } +
+
+ ); +} + +/** Error log item in the list */ +interface ErrorLogItemProps { + name: string; + size: number; + modified: number; + isSelected: boolean; + onClick: () => void; +} + +function ErrorLogItem({ name, size, modified, isSelected, onClick }: ErrorLogItemProps) { + const parsed = useMemo(() => parseFilename(name), [name]); + + return ( + + ); +} + +export function ErrorLogsMonitor() { + const { data: status, isLoading: isStatusLoading } = useCliproxyStatus(); + const { data: logs, isLoading, error } = useCliproxyErrorLogs(status?.running ?? false); + + // Vertical resize state + const [height, setHeight] = useState(500); + const [isResizing, setIsResizing] = useState(false); + const containerRef = useRef(null); + const scrollIntervalRef = useRef(null); + + // Auto-scroll handler + const stopAutoScroll = () => { + if (scrollIntervalRef.current) { + clearInterval(scrollIntervalRef.current); + scrollIntervalRef.current = null; + } + }; + + // Resize handlers + useEffect(() => { + if (!isResizing) return; + + const handleMouseMove = (e: MouseEvent) => { + const container = containerRef.current; + if (!container) return; + + const rect = container.getBoundingClientRect(); + const containerTopDoc = rect.top + window.scrollY; + const newHeight = e.pageY - containerTopDoc; + + // Constrain height (min 300, no max) + setHeight(Math.max(300, newHeight)); + + // Auto-scroll logic + const viewportHeight = window.innerHeight; + const distFromBottom = viewportHeight - e.clientY; + const scrollSpeed = 15; + + stopAutoScroll(); + + if (distFromBottom < 50) { + scrollIntervalRef.current = setInterval(() => { + window.scrollBy(0, scrollSpeed); + }, 16); + } else if (e.clientY < 50) { + scrollIntervalRef.current = setInterval(() => { + window.scrollBy(0, -scrollSpeed); + }, 16); + } + }; + + const handleMouseUp = () => { + setIsResizing(false); + stopAutoScroll(); + document.body.style.cursor = 'default'; + document.body.style.userSelect = 'auto'; + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'row-resize'; + document.body.style.userSelect = 'none'; + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'default'; + document.body.style.userSelect = 'auto'; + stopAutoScroll(); + }; + }, [isResizing]); + + const startResizing = (e: React.MouseEvent) => { + e.preventDefault(); + setIsResizing(true); + }; + + // Compute default selection (first log name or null) + const defaultLogName = useMemo(() => logs?.[0]?.name ?? null, [logs]); + + // Use controlled selection that defaults to first log + const [selectedLog, setSelectedLog] = useState(null); + + // Effective selection: use user selection if available, otherwise default + const effectiveSelection = selectedLog ?? defaultLogName; + + // Get absolute path for the selected log + const selectedAbsolutePath = useMemo(() => { + if (!effectiveSelection || !logs) return undefined; + const log = logs.find((l) => l.name === effectiveSelection); + return log?.absolutePath; + }, [effectiveSelection, logs]); + + // Guards + if (isStatusLoading) return null; + if (!status?.running) return null; + if (isLoading) { + return ( +
+
+ + +
+
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ ); + } + if (!logs || logs.length === 0) return null; + + const errorCount = logs.length; + + return ( +
+ {/* Header */} +
+
+ + Error Logs + + {errorCount} failed request{errorCount !== 1 ? 's' : ''} + +
+
+ + CLIProxy Diagnostics +
+
+ + {/* Resizable Panel Layout */} +
+ + {/* Left Panel: Log List */} + + +
+ {logs.slice(0, 50).map((log) => ( + setSelectedLog(log.name)} + /> + ))} +
+ {logs.length > 50 && ( +
+ Showing 50 of {logs.length} logs +
+ )} +
+
+ + {/* Resize Handle */} + +
+
+ +
+ + + {/* Right Panel: Log Content */} + + + + +
+ + {/* Use standard footer if error, otherwise show resize handle */} + {error ? ( +
+ {error.message} +
+ ) : ( +
+ +
+ )} +
+ ); +} diff --git a/ui/src/components/global-env-indicator.tsx b/ui/src/components/global-env-indicator.tsx new file mode 100644 index 00000000..b6cc0cf3 --- /dev/null +++ b/ui/src/components/global-env-indicator.tsx @@ -0,0 +1,132 @@ +/** + * Global Environment Variables Indicator + * + * Shows which env vars from global_env will be injected at runtime. + * Displayed below the Raw Configuration (JSON) section in profile editors. + */ + +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { Settings2, ChevronDown, ChevronUp, ExternalLink, Info } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface GlobalEnvConfig { + enabled: boolean; + env: Record; +} + +interface GlobalEnvIndicatorProps { + /** Current profile's env vars (to show which are overridden) */ + profileEnv?: Record; +} + +export function GlobalEnvIndicator({ profileEnv = {} }: GlobalEnvIndicatorProps) { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + fetchConfig(); + }, []); + + const fetchConfig = async () => { + try { + setLoading(true); + const res = await fetch('/api/global-env'); + if (!res.ok) throw new Error('Failed to load'); + const data = await res.json(); + setConfig(data); + } catch { + setConfig(null); + } finally { + setLoading(false); + } + }; + + // Don't render if loading or disabled or no vars + if (loading) return null; + if (!config?.enabled) return null; + + const envVars = config.env || {}; + const envKeys = Object.keys(envVars); + if (envKeys.length === 0) return null; + + // Check which keys are already in profile (won't be overridden) + const injectedKeys = envKeys.filter((key) => !(key in profileEnv)); + const overriddenKeys = envKeys.filter((key) => key in profileEnv); + + return ( +
+ {/* Header - clickable to expand */} + + + {/* Expanded content */} + {expanded && ( +
+ {/* Injected vars */} + {injectedKeys.length > 0 && ( +
+ {injectedKeys.map((key) => ( +
+ + + + {key}={envVars[key]} + +
+ ))} +
+ )} + + {/* Overridden vars (profile takes precedence) */} + {overriddenKeys.length > 0 && ( +
+

Skipped (profile already defines):

+ {overriddenKeys.map((key) => ( +
+ ~ + {key} +
+ ))} +
+ )} + + {/* Link to settings */} +
+ +
+
+ )} +
+ ); +} diff --git a/ui/src/components/profile-editor.tsx b/ui/src/components/profile-editor.tsx index 5a763587..b582c00d 100644 --- a/ui/src/components/profile-editor.tsx +++ b/ui/src/components/profile-editor.tsx @@ -16,6 +16,7 @@ import { Save, Loader2, Code2, Trash2, RefreshCw, Plus, X, Info } from 'lucide-r import { toast } from 'sonner'; import { CopyButton } from '@/components/ui/copy-button'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { GlobalEnvIndicator } from '@/components/global-env-indicator'; // Lazy load CodeEditor to reduce initial bundle size const CodeEditor = lazy(() => @@ -412,7 +413,7 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { Invalid JSON syntax
)} -
+
+ {/* Global Env Indicator */} +
+
+ +
+
); diff --git a/ui/src/components/proxy-status-widget.tsx b/ui/src/components/proxy-status-widget.tsx index a85981c2..bfa0a2c3 100644 --- a/ui/src/components/proxy-status-widget.tsx +++ b/ui/src/components/proxy-status-widget.tsx @@ -1,13 +1,19 @@ /** * Proxy Status Widget * - * Displays CLIProxy process status with start button for recovery. - * Shows: running state, port, session count, uptime. + * Displays CLIProxy process status with start/stop/restart controls. + * Shows: running state, port, session count, uptime, update availability. */ -import { Activity, Power, RefreshCw, Clock, Users } from 'lucide-react'; +import { Activity, Power, RefreshCw, Clock, Users, Square, RotateCw, ArrowUp } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { useProxyStatus, useStartProxy } from '@/hooks/use-cliproxy'; +import { Badge } from '@/components/ui/badge'; +import { + useProxyStatus, + useStartProxy, + useStopProxy, + useCliproxyUpdateCheck, +} from '@/hooks/use-cliproxy'; import { cn } from '@/lib/utils'; function formatUptime(startedAt?: string): string { @@ -25,11 +31,34 @@ function formatUptime(startedAt?: string): string { return `${minutes}m`; } +function formatTimeAgo(timestamp?: number): string { + if (!timestamp) return ''; + const diff = Date.now() - timestamp; + const minutes = Math.floor(diff / (1000 * 60)); + const hours = Math.floor(diff / (1000 * 60 * 60)); + + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + return `${hours}h ago`; +} + export function ProxyStatusWidget() { const { data: status, isLoading } = useProxyStatus(); + const { data: updateCheck } = useCliproxyUpdateCheck(); const startProxy = useStartProxy(); + const stopProxy = useStopProxy(); const isRunning = status?.running ?? false; + const isActioning = startProxy.isPending || stopProxy.isPending; + const hasUpdate = updateCheck?.hasUpdate ?? false; + + // Restart = stop then start + const handleRestart = async () => { + await stopProxy.mutateAsync(); + // Small delay to ensure port is released + await new Promise((r) => setTimeout(r, 500)); + startProxy.mutate(); + }; return (
CLIProxy Service + {hasUpdate && ( + v${updateCheck?.latestVersion}`} + > + + Update + + )}
@@ -61,21 +100,66 @@ export function ProxyStatusWidget() {
{isRunning && status ? ( -
- Port {status.port} - {status.sessionCount !== undefined && status.sessionCount > 0 && ( - - - {status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''} - - )} - {status.startedAt && ( - - - {formatUptime(status.startedAt)} - - )} -
+ <> +
+ Port {status.port} + {status.sessionCount !== undefined && status.sessionCount > 0 && ( + + + {status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''} + + )} + {status.startedAt && ( + + + {formatUptime(status.startedAt)} + + )} +
+ {/* Control buttons when running */} +
+ + +
+ ) : (
Not running @@ -95,6 +179,18 @@ export function ProxyStatusWidget() {
)} + + {/* Version sync indicator */} + {updateCheck?.currentVersion && ( +
+ v{updateCheck.currentVersion} + {updateCheck.checkedAt && ( + + Synced {formatTimeAgo(updateCheck.checkedAt)} + + )} +
+ )} ); } diff --git a/ui/src/components/ui/copy-button.tsx b/ui/src/components/ui/copy-button.tsx index 5453f328..3f296151 100644 --- a/ui/src/components/ui/copy-button.tsx +++ b/ui/src/components/ui/copy-button.tsx @@ -3,19 +3,21 @@ import { useState } from 'react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { type VariantProps } from 'class-variance-authority'; +import { type buttonVariants } from '@/components/ui/button-variants'; interface CopyButtonProps { value: string; className?: string; - variant?: 'default' | 'outline' | 'ghost' | 'secondary'; - size?: 'default' | 'sm' | 'lg' | 'icon'; + variant?: VariantProps['variant']; + size?: VariantProps['size']; label?: string; } export function CopyButton({ value, className, - variant = 'ghost', + variant = 'outline', size = 'icon', label = 'Copy to clipboard', }: CopyButtonProps) { @@ -34,19 +36,16 @@ export function CopyButton({ diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 3d27c7a9..2e825e01 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -130,3 +130,62 @@ export function useCliproxyModels(enabled = true) { retry: 1, }); } + +/** Error log file metadata from CLIProxyAPI */ +export interface CliproxyErrorLog { + name: string; + size: number; + modified: number; + /** Absolute path to the log file (injected by backend) */ + absolutePath?: string; +} + +/** + * Fetch CLIProxy error logs from API + */ +async function fetchCliproxyErrorLogs(): Promise { + const response = await fetch('/api/cliproxy/error-logs'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to fetch error logs'); + } + const data = await response.json(); + return data.files ?? []; +} + +/** + * Fetch specific error log content + */ +async function fetchCliproxyErrorLogContent(name: string): Promise { + const response = await fetch(`/api/cliproxy/error-logs/${encodeURIComponent(name)}`); + if (!response.ok) { + throw new Error('Failed to fetch error log content'); + } + return response.text(); +} + +/** + * Hook to get CLIProxy error logs list + */ +export function useCliproxyErrorLogs(enabled = true) { + return useQuery({ + queryKey: ['cliproxy-error-logs'], + queryFn: fetchCliproxyErrorLogs, + enabled, + refetchInterval: 30000, // Refresh every 30 seconds + retry: 1, + staleTime: 10000, + }); +} + +/** + * Hook to get specific error log content + */ +export function useCliproxyErrorLogContent(name: string | null) { + return useQuery({ + queryKey: ['cliproxy-error-log-content', name], + queryFn: () => (name ? fetchCliproxyErrorLogContent(name) : Promise.resolve('')), + enabled: !!name, + staleTime: 60000, // Cache log content for 1 minute + }); +} diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index 9f23842e..fa93c06f 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -240,3 +240,36 @@ export function useStartProxy() { }, }); } + +export function useStopProxy() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => api.cliproxy.proxyStop(), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['proxy-status'] }); + if (data.stopped) { + toast.success( + `CLIProxy stopped${data.sessionCount ? ` (${data.sessionCount} session(s) disconnected)` : ''}` + ); + } else { + toast.error(data.error || 'Failed to stop CLIProxy'); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +// ==================== Update Check ==================== + +export function useCliproxyUpdateCheck() { + return useQuery({ + queryKey: ['cliproxy-update-check'], + queryFn: () => api.cliproxy.updateCheck(), + staleTime: 60 * 60 * 1000, // 1 hour (matches backend cache) + refetchInterval: 60 * 60 * 1000, // Refresh every hour + refetchOnWindowFocus: false, // Don't refresh on window focus (save API calls) + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 7ca955b4..4e0f6d21 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -154,6 +154,42 @@ export interface CreatePreset { haiku?: string; } +/** Remote proxy status from health check */ +export interface RemoteProxyStatus { + reachable: boolean; + latencyMs?: number; + error?: string; + errorCode?: 'CONNECTION_REFUSED' | 'TIMEOUT' | 'AUTH_FAILED' | 'UNKNOWN'; +} + +/** Remote proxy configuration */ +export interface ProxyRemoteConfig { + enabled: boolean; + host: string; + port: number; + protocol: 'http' | 'https'; + auth_token: string; +} + +/** Fallback configuration */ +export interface ProxyFallbackConfig { + enabled: boolean; + auto_start: boolean; +} + +/** Local proxy configuration */ +export interface ProxyLocalConfig { + port: number; + auto_start: boolean; +} + +/** CLIProxy server configuration */ +export interface CliproxyServerConfig { + remote: ProxyRemoteConfig; + fallback: ProxyFallbackConfig; + local: ProxyLocalConfig; +} + /** CLIProxy process status from session tracker */ export interface ProxyProcessStatus { running: boolean; @@ -163,6 +199,16 @@ export interface ProxyProcessStatus { startedAt?: string; } +/** Error log file metadata from CLIProxyAPI */ +export interface CliproxyErrorLog { + /** Filename (e.g., "error-v1-chat-completions-2025-01-15T10-30-00.log") */ + name: string; + /** File size in bytes */ + size: number; + /** Last modified timestamp (Unix seconds) */ + modified: number; +} + /** Result from starting proxy service */ export interface ProxyStartResult { started: boolean; @@ -172,6 +218,23 @@ export interface ProxyStartResult { error?: string; } +/** Result from stopping proxy service */ +export interface ProxyStopResult { + stopped: boolean; + pid?: number; + sessionCount?: number; + error?: string; +} + +/** Result from checking for CLIProxyAPI updates */ +export interface CliproxyUpdateCheckResult { + hasUpdate: boolean; + currentVersion: string; + latestVersion: string; + fromCache: boolean; + checkedAt: number; // Unix timestamp of last check +} + // API export const api = { profiles: { @@ -206,6 +269,8 @@ export const api = { // Proxy process status and control proxyStatus: () => request('/cliproxy/proxy-status'), proxyStart: () => request('/cliproxy/proxy-start', { method: 'POST' }), + proxyStop: () => request('/cliproxy/proxy-stop', { method: 'POST' }), + updateCheck: () => request('/cliproxy/update-check'), // Stats and models for Overview tab stats: () => request<{ usage: Record }>('/cliproxy/usage'), @@ -266,6 +331,17 @@ export const api = { body: JSON.stringify({ nickname }), }), }, + // Error logs + errorLogs: { + /** List error log files */ + list: () => request<{ files: CliproxyErrorLog[] }>('/cliproxy/error-logs'), + /** Get content of a specific error log */ + getContent: async (name: string): Promise => { + const res = await fetch(`${BASE_URL}/cliproxy/error-logs/${encodeURIComponent(name)}`); + if (!res.ok) throw new Error('Failed to load error log'); + return res.text(); + }, + }, }, accounts: { list: () => request<{ accounts: Account[]; default: string | null }>('/accounts'), @@ -313,4 +389,27 @@ export const api = { method: 'DELETE', }), }, + /** CLIProxy server configuration API */ + cliproxyServer: { + /** Get cliproxy server configuration */ + get: () => request('/cliproxy-server'), + /** Update cliproxy server configuration */ + update: (config: Partial) => + request('/cliproxy-server', { + method: 'PUT', + body: JSON.stringify(config), + }), + /** Test remote proxy connection */ + test: (params: { + host: string; + port: number; + protocol: 'http' | 'https'; + authToken?: string; + allowSelfSigned?: boolean; + }) => + request('/cliproxy-server/test', { + method: 'POST', + body: JSON.stringify(params), + }), + }, }; diff --git a/ui/src/lib/error-log-parser.ts b/ui/src/lib/error-log-parser.ts new file mode 100644 index 00000000..3afa030c --- /dev/null +++ b/ui/src/lib/error-log-parser.ts @@ -0,0 +1,328 @@ +/** + * Error Log Parser Utility + * + * Parses CLIProxy error log content into structured data for display. + * Extracts request info, headers, body, and response sections. + */ + +/** Parsed error log structure */ +export interface ParsedErrorLog { + // Request Info + version: string; + url: string; + method: string; + timestamp: string; + + // Response + statusCode: number; + statusText: string; + + // Sections (raw strings) + requestHeaders: Record; + requestBody: string; + responseHeaders: Record; + responseBody: string; + + // Computed metadata + provider: string; + endpoint: string; + isClientError: boolean; + isServerError: boolean; + errorType: 'rate_limit' | 'auth' | 'not_found' | 'server' | 'timeout' | 'unknown'; +} + +/** Parsed filename metadata */ +export interface ParsedFilename { + provider: string; + endpoint: string; + timestamp: Date; + raw: string; +} + +/** + * Parse error log filename to extract provider, endpoint, and timestamp + * Format: error-api-provider-{provider}-api-{endpoint}-{timestamp}-{id}.log + */ +export function parseFilename(name: string): ParsedFilename { + const result: ParsedFilename = { + provider: 'unknown', + endpoint: 'unknown', + timestamp: new Date(), + raw: name, + }; + + // Extract provider: error-api-provider-{PROVIDER}-api-... + const providerMatch = name.match(/error-api-provider-([^-]+)-/); + if (providerMatch) { + result.provider = providerMatch[1]; + } + + // Extract endpoint from after provider: ...-api-{ENDPOINT}-{timestamp} + // Example: error-api-provider-agy-api-event_logging-batch-2025-12-18T185041-... + const endpointMatch = name.match(/-api-([a-z_]+(?:-[a-z_]+)*)-\d{4}-\d{2}-\d{2}T/i); + if (endpointMatch) { + result.endpoint = endpointMatch[1].replace(/-/g, '/'); + } + + // Extract timestamp: 2025-12-18T185041 + const tsMatch = name.match(/(\d{4}-\d{2}-\d{2}T\d{6})/); + if (tsMatch) { + const ts = tsMatch[1]; + // Parse: 2025-12-18T185041 → 2025-12-18T18:50:41 + const formatted = `${ts.slice(0, 10)}T${ts.slice(11, 13)}:${ts.slice(13, 15)}:${ts.slice(15, 17)}`; + result.timestamp = new Date(formatted); + } + + return result; +} + +/** + * Parse raw error log content into structured data + */ +export function parseErrorLog(content: string): ParsedErrorLog { + const result: ParsedErrorLog = { + version: '', + url: '', + method: '', + timestamp: '', + statusCode: 0, + statusText: '', + requestHeaders: {}, + requestBody: '', + responseHeaders: {}, + responseBody: '', + provider: '', + endpoint: '', + isClientError: false, + isServerError: false, + errorType: 'unknown', + }; + + // Split into sections + const sections = content.split(/^===\s*(.+?)\s*===$/m); + + let currentSection = ''; + for (let i = 0; i < sections.length; i++) { + const part = sections[i].trim(); + + if (part === 'REQUEST INFO') { + currentSection = 'request_info'; + continue; + } else if (part === 'HEADERS') { + currentSection = 'headers'; + continue; + } else if (part === 'REQUEST BODY') { + currentSection = 'request_body'; + continue; + } else if (part === 'RESPONSE') { + currentSection = 'response'; + continue; + } + + // Parse section content + switch (currentSection) { + case 'request_info': + parseRequestInfo(part, result); + break; + case 'headers': + result.requestHeaders = parseHeaders(part); + break; + case 'request_body': + result.requestBody = part; + break; + case 'response': + parseResponse(part, result); + break; + } + } + + // Compute derived fields + computeDerivedFields(result); + + return result; +} + +/** Parse REQUEST INFO section */ +function parseRequestInfo(content: string, result: ParsedErrorLog): void { + const lines = content.split('\n'); + for (const line of lines) { + const [key, ...valueParts] = line.split(':'); + const value = valueParts.join(':').trim(); + + switch (key?.trim()?.toLowerCase()) { + case 'version': + result.version = value; + break; + case 'url': + result.url = value; + break; + case 'method': + result.method = value; + break; + case 'timestamp': + result.timestamp = value; + break; + } + } +} + +/** Parse headers into key-value object */ +function parseHeaders(content: string): Record { + const headers: Record = {}; + const lines = content.split('\n'); + + for (const line of lines) { + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + if (key) headers[key] = value; + } + } + + return headers; +} + +/** Parse RESPONSE section */ +function parseResponse(content: string, result: ParsedErrorLog): void { + const lines = content.split('\n'); + let headersEnded = false; + const bodyLines: string[] = []; + + for (const line of lines) { + // First line might be "Status: 404" + if (line.startsWith('Status:')) { + const statusStr = line.replace('Status:', '').trim(); + const statusParts = statusStr.split(/\s+/); + result.statusCode = parseInt(statusParts[0], 10) || 0; + result.statusText = statusParts.slice(1).join(' ') || getStatusText(result.statusCode); + continue; + } + + // Check for empty line (separates headers from body) + if (line.trim() === '' && !headersEnded) { + headersEnded = true; + continue; + } + + // Parse response headers + if (!headersEnded) { + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + if (key) result.responseHeaders[key] = value; + } + } else { + bodyLines.push(line); + } + } + + result.responseBody = bodyLines.join('\n').trim(); +} + +/** Compute derived fields from parsed data */ +function computeDerivedFields(result: ParsedErrorLog): void { + // Extract provider from URL: /api/provider/{PROVIDER}/... + const providerMatch = result.url.match(/\/api\/provider\/([^/]+)/); + if (providerMatch) { + result.provider = providerMatch[1]; + } + + // Extract endpoint from URL + const endpointMatch = result.url.match(/\/api\/provider\/[^/]+\/api\/(.+)/); + if (endpointMatch) { + result.endpoint = endpointMatch[1]; + } + + // Status code classification + result.isClientError = result.statusCode >= 400 && result.statusCode < 500; + result.isServerError = result.statusCode >= 500; + + // Error type classification + if (result.statusCode === 429) { + result.errorType = 'rate_limit'; + } else if (result.statusCode === 401 || result.statusCode === 403) { + result.errorType = 'auth'; + } else if (result.statusCode === 404) { + result.errorType = 'not_found'; + } else if (result.statusCode >= 500) { + result.errorType = 'server'; + } else if (result.statusCode === 408 || result.statusCode === 504) { + result.errorType = 'timeout'; + } +} + +/** Get status text for common codes */ +function getStatusText(code: number): string { + const statusTexts: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 408: 'Request Timeout', + 429: 'Too Many Requests', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + }; + return statusTexts[code] || ''; +} + +/** + * Format Unix timestamp (seconds) to relative time string + */ +export function formatRelativeTime(modifiedSeconds: number): string { + const now = Date.now(); + const modified = modifiedSeconds * 1000; // Convert to milliseconds + const diff = now - modified; + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + + // Format as date for older logs + const date = new Date(modified); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +/** + * Format bytes to human readable size + */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** + * Get status code badge color class + */ +export function getStatusColor(code: number): string { + if (code >= 500) return 'text-red-500'; + if (code === 429) return 'text-orange-500'; + if (code >= 400) return 'text-yellow-500'; + return 'text-gray-500'; +} + +/** + * Get error type label + */ +export function getErrorTypeLabel(type: ParsedErrorLog['errorType']): string { + const labels: Record = { + rate_limit: 'Rate Limited', + auth: 'Auth Error', + not_found: 'Not Found', + server: 'Server Error', + timeout: 'Timeout', + unknown: 'Error', + }; + return labels[type] || 'Error'; +} diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index b296c7fe..80d013fb 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -227,7 +227,7 @@ export function CliproxyPage() { return (
{/* Left Sidebar */} -
+
{/* Header */}
diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index ccc5bf92..e864b87a 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -1,6 +1,7 @@ import { useNavigate } from 'react-router-dom'; import { HeroSection } from '@/components/hero-section'; import { AuthMonitor } from '@/components/auth-monitor'; +import { ErrorLogsMonitor } from '@/components/error-logs-monitor'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Skeleton } from '@/components/ui/skeleton'; import { Key, Zap, Users, Activity, AlertTriangle } from 'lucide-react'; @@ -175,6 +176,9 @@ export function HomePage() { {/* Auth Monitor */} + + {/* Error Logs Monitor - shows only when there are errors */} +
); } diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index d14175db..1fdb9138 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -1,15 +1,24 @@ /** - * Settings Page - WebSearch Configuration - * Supports Gemini CLI and Grok CLI providers + * Settings Page - WebSearch, Global Env & Proxy Configuration + * Supports Gemini CLI and Grok CLI providers + Global Environment Variables + Proxy Settings */ import { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/input'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Globe, RefreshCw, @@ -23,8 +32,18 @@ import { ExternalLink, ChevronDown, ChevronUp, + Settings2, + Plus, + Trash2, + Server, + Laptop, + Cloud, + Wifi, + WifiOff, } from 'lucide-react'; import { CodeEditor } from '@/components/code-editor'; +import { api } from '@/lib/api-client'; +import type { CliproxyServerConfig, RemoteProxyStatus } from '@/lib/api-client'; interface ProviderConfig { enabled?: boolean; @@ -59,7 +78,17 @@ interface WebSearchStatus { }; } +interface GlobalEnvConfig { + enabled: boolean; + env: Record; +} + export function SettingsPage() { + const [searchParams] = useSearchParams(); + const tabParam = searchParams.get('tab'); + const initialTab = + tabParam === 'globalenv' ? 'globalenv' : tabParam === 'proxy' ? 'proxy' : 'websearch'; + const [activeTab, setActiveTab] = useState<'websearch' | 'globalenv' | 'proxy'>(initialTab); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -78,12 +107,31 @@ export function SettingsPage() { const [showGeminiHint, setShowGeminiHint] = useState(false); const [showOpencodeHint, setShowOpencodeHint] = useState(false); const [showGrokHint, setShowGrokHint] = useState(false); + // Global Env state + const [globalEnvConfig, setGlobalEnvConfig] = useState(null); + const [globalEnvLoading, setGlobalEnvLoading] = useState(true); + const [globalEnvSaving, setGlobalEnvSaving] = useState(false); + const [globalEnvError, setGlobalEnvError] = useState(null); + const [globalEnvSuccess, setGlobalEnvSuccess] = useState(false); + // New env var inputs + const [newEnvKey, setNewEnvKey] = useState(''); + const [newEnvValue, setNewEnvValue] = useState(''); + // Proxy state + const [proxyConfig, setCliproxyServerConfig] = useState(null); + const [proxyLoading, setProxyLoading] = useState(true); + const [proxySaving, setProxySaving] = useState(false); + const [proxyError, setProxyError] = useState(null); + const [proxySuccess, setProxySuccess] = useState(false); + const [testResult, setTestResult] = useState(null); + const [testing, setTesting] = useState(false); // Load config and status on mount useEffect(() => { fetchConfig(); fetchStatus(); fetchRawConfig(); + fetchGlobalEnvConfig(); + fetchCliproxyServerConfig(); }, []); // Sync local model inputs when config changes @@ -141,6 +189,34 @@ export function SettingsPage() { } }; + const fetchGlobalEnvConfig = async () => { + try { + setGlobalEnvLoading(true); + setGlobalEnvError(null); + const res = await fetch('/api/global-env'); + if (!res.ok) throw new Error('Failed to load Global Env config'); + const data = await res.json(); + setGlobalEnvConfig(data); + } catch (err) { + setGlobalEnvError((err as Error).message); + } finally { + setGlobalEnvLoading(false); + } + }; + + const fetchCliproxyServerConfig = async () => { + try { + setProxyLoading(true); + setProxyError(null); + const data = await api.cliproxyServer.get(); + setCliproxyServerConfig(data); + } catch (err) { + setProxyError((err as Error).message); + } finally { + setProxyLoading(false); + } + }; + const copyToClipboard = async () => { if (!rawConfig) return; try { @@ -284,6 +360,133 @@ export function SettingsPage() { } }; + // Global Env functions + const saveGlobalEnvConfig = async (updates: Partial) => { + if (!globalEnvConfig) return; + + // Optimistic update + const optimisticConfig = { ...globalEnvConfig, ...updates }; + setGlobalEnvConfig(optimisticConfig); + + try { + setGlobalEnvSaving(true); + setGlobalEnvError(null); + + const res = await fetch('/api/global-env', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(optimisticConfig), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to save'); + } + + const data = await res.json(); + setGlobalEnvConfig(data.config); + setGlobalEnvSuccess(true); + setTimeout(() => setGlobalEnvSuccess(false), 1500); + // Silently refresh raw config + fetch('/api/config/raw') + .then((r) => (r.ok ? r.text() : null)) + .then((text) => text && setRawConfig(text)) + .catch(() => {}); + } catch (err) { + setGlobalEnvConfig(globalEnvConfig); + setGlobalEnvError((err as Error).message); + } finally { + setGlobalEnvSaving(false); + } + }; + + const toggleGlobalEnv = () => { + saveGlobalEnvConfig({ enabled: !globalEnvConfig?.enabled }); + }; + + const addEnvVar = () => { + if (!newEnvKey.trim() || !globalEnvConfig) return; + const newEnv = { ...globalEnvConfig.env, [newEnvKey.trim()]: newEnvValue }; + saveGlobalEnvConfig({ env: newEnv }); + setNewEnvKey(''); + setNewEnvValue(''); + }; + + const removeEnvVar = (key: string) => { + if (!globalEnvConfig) return; + const newEnv = { ...globalEnvConfig.env }; + delete newEnv[key]; + saveGlobalEnvConfig({ env: newEnv }); + }; + + const updateEnvValue = (key: string, value: string) => { + if (!globalEnvConfig) return; + const newEnv = { ...globalEnvConfig.env, [key]: value }; + saveGlobalEnvConfig({ env: newEnv }); + }; + + // Proxy functions + const saveCliproxyServerConfig = async (updates: Partial) => { + if (!proxyConfig) return; + + // Optimistic update + const optimisticConfig = { + remote: { ...proxyConfig.remote, ...updates.remote }, + fallback: { ...proxyConfig.fallback, ...updates.fallback }, + local: { ...proxyConfig.local, ...updates.local }, + }; + setCliproxyServerConfig(optimisticConfig); + setTestResult(null); // Clear previous test result on config change + + try { + setProxySaving(true); + setProxyError(null); + + const data = await api.cliproxyServer.update(updates); + setCliproxyServerConfig(data); + setProxySuccess(true); + setTimeout(() => setProxySuccess(false), 1500); + // Silently refresh raw config + fetch('/api/config/raw') + .then((r) => (r.ok ? r.text() : null)) + .then((text) => text && setRawConfig(text)) + .catch(() => {}); + } catch (err) { + setCliproxyServerConfig(proxyConfig); + setProxyError((err as Error).message); + } finally { + setProxySaving(false); + } + }; + + const handleTestConnection = async () => { + if (!proxyConfig) return; + + const { host, port, protocol, auth_token } = proxyConfig.remote; + if (!host || !port) { + setProxyError('Host and port are required'); + return; + } + + try { + setTesting(true); + setProxyError(null); + setTestResult(null); + + const result = await api.cliproxyServer.test({ + host, + port, + protocol, + authToken: auth_token || undefined, + }); + setTestResult(result); + } catch (err) { + setProxyError((err as Error).message); + } finally { + setTesting(false); + } + }; + if (loading) { return (
@@ -298,348 +501,97 @@ export function SettingsPage() { return (
- {/* Left Panel - WebSearch Controls */} + {/* Left Panel - Settings Controls */}
- {/* Header */} + {/* Header with Tabs */}
-
- -
-

WebSearch

-

- CLI-based web search for third-party profiles -

-
-
-
- - {/* Toast-style alerts - absolute positioned, no layout shift */} -
- {error && ( - - - {error} - - )} - {success && ( -
- - Saved -
- )} -
- - {/* Scrollable Content */} - -
- {/* Status Summary */} -
-
-

- {isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'} -

- {statusLoading ? ( -

Checking status...

- ) : status?.readiness ? ( -

{status.readiness.message}

- ) : null} -
- -
- - {/* CLI Providers */} -
-

Providers

- - {/* Gemini CLI Provider */} -
-
-
- -
-
-

gemini

- - FREE - - {status?.geminiCli?.installed ? ( - - installed - - ) : ( - - not installed - - )} -
-

- Google Gemini CLI (1000 req/day free) -

-
-
- -
- {/* Model input when enabled */} - {isGeminiEnabled && ( -
-
- - setGeminiModelInput(e.target.value)} - onBlur={saveGeminiModel} - placeholder="gemini-2.5-flash" - className="h-8 text-sm font-mono" - disabled={saving} - /> -
-
- )} - {/* Installation hint when not installed - inside card */} - {!status?.geminiCli?.installed && !statusLoading && ( -
- - {showGeminiHint && ( -
-

- Install globally (FREE tier available): -

- - npm install -g @google/gemini-cli - - - - View documentation - -
- )} -
- )} -
- - {/* OpenCode CLI Provider */} -
-
-
- -
-
-

opencode

- - FREE - - {status?.opencodeCli?.installed ? ( - - installed - - ) : ( - - not installed - - )} -
-

- OpenCode (web search via Zen) -

-
-
- -
- {/* Model input when enabled */} - {isOpenCodeEnabled && ( -
-
- - setOpencodeModelInput(e.target.value)} - onBlur={saveOpencodeModel} - placeholder="opencode/grok-code" - className="h-8 text-sm font-mono" - disabled={saving} - /> -
-
- )} - {/* Installation hint when not installed - inside card */} - {!status?.opencodeCli?.installed && !statusLoading && ( -
- - {showOpencodeHint && ( -
-

- Install globally (FREE tier available): -

- - curl -fsSL https://opencode.ai/install | bash - - - - View documentation - -
- )} -
- )} -
- - {/* Grok CLI Provider */} -
-
-
- -
-
-

grok

- - GROK_API_KEY - - {status?.grokCli?.installed ? ( - - installed - - ) : ( - - not installed - - )} -
-

- xAI Grok CLI (web + X search) -

-
-
- -
- {/* Installation hint when not installed - inside card */} - {!status?.grokCli?.installed && !statusLoading && ( -
- - {showGrokHint && ( -
-

- Install globally (requires xAI API key): -

- - npm install -g @vibe-kit/grok-cli - - - - View documentation - -
- )} -
- )} -
-
-
-
- - {/* Footer */} -
- + + + + WebSearch + + + + Global Env + + + + Proxy + + +
+ + {/* Tab Content */} + {activeTab === 'websearch' ? ( + + ) : activeTab === 'globalenv' ? ( + + ) : ( + + )}
@@ -719,3 +671,1026 @@ export function SettingsPage() {
); } + +// WebSearch Tab Content Component +interface WebSearchContentProps { + config: WebSearchConfig | null; + status: WebSearchStatus | null; + statusLoading: boolean; + saving: boolean; + error: string | null; + success: boolean; + isGeminiEnabled: boolean; + isGrokEnabled: boolean; + isOpenCodeEnabled: boolean; + geminiModelInput: string; + opencodeModelInput: string; + showGeminiHint: boolean; + showOpencodeHint: boolean; + showGrokHint: boolean; + setGeminiModelInput: (v: string) => void; + setOpencodeModelInput: (v: string) => void; + setShowGeminiHint: (v: boolean) => void; + setShowOpencodeHint: (v: boolean) => void; + setShowGrokHint: (v: boolean) => void; + toggleGemini: () => void; + toggleGrok: () => void; + toggleOpenCode: () => void; + saveGeminiModel: () => void; + saveOpencodeModel: () => void; + fetchStatus: () => void; + fetchConfig: () => void; + fetchRawConfig: () => void; + loading: boolean; +} + +function WebSearchContent({ + status, + statusLoading, + saving, + error, + success, + isGeminiEnabled, + isGrokEnabled, + isOpenCodeEnabled, + geminiModelInput, + opencodeModelInput, + showGeminiHint, + showOpencodeHint, + showGrokHint, + setGeminiModelInput, + setOpencodeModelInput, + setShowGeminiHint, + setShowOpencodeHint, + setShowGrokHint, + toggleGemini, + toggleGrok, + toggleOpenCode, + saveGeminiModel, + saveOpencodeModel, + fetchStatus, + fetchConfig, + fetchRawConfig, + loading, +}: WebSearchContentProps) { + return ( + <> + {/* Toast-style alerts - absolute positioned, no layout shift */} +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + Saved +
+ )} +
+ + {/* Scrollable Content */} + +
+ {/* Description */} +

+ CLI-based web search for third-party profiles (gemini, codex, agy, etc.) +

+ + {/* Status Summary */} +
+
+

+ {isGeminiEnabled ? 'WebSearch enabled' : 'WebSearch disabled'} +

+ {statusLoading ? ( +

Checking status...

+ ) : status?.readiness ? ( +

{status.readiness.message}

+ ) : null} +
+ +
+ + {/* CLI Providers */} +
+

Providers

+ + {/* Gemini CLI Provider */} +
+
+
+ +
+
+

gemini

+ + FREE + + {status?.geminiCli?.installed ? ( + + installed + + ) : ( + + not installed + + )} +
+

+ Google Gemini CLI (1000 req/day free) +

+
+
+ +
+ {/* Model input when enabled */} + {isGeminiEnabled && ( +
+
+ + setGeminiModelInput(e.target.value)} + onBlur={saveGeminiModel} + placeholder="gemini-2.5-flash" + className="h-8 text-sm font-mono" + disabled={saving} + /> +
+
+ )} + {/* Installation hint when not installed - inside card */} + {!status?.geminiCli?.installed && !statusLoading && ( +
+ + {showGeminiHint && ( +
+

+ Install globally (FREE tier available): +

+ + npm install -g @google/gemini-cli + + + + View documentation + +
+ )} +
+ )} +
+ + {/* OpenCode CLI Provider */} +
+
+
+ +
+
+

opencode

+ + FREE + + {status?.opencodeCli?.installed ? ( + + installed + + ) : ( + + not installed + + )} +
+

OpenCode (web search via Zen)

+
+
+ +
+ {/* Model input when enabled */} + {isOpenCodeEnabled && ( +
+
+ + setOpencodeModelInput(e.target.value)} + onBlur={saveOpencodeModel} + placeholder="opencode/grok-code" + className="h-8 text-sm font-mono" + disabled={saving} + /> +
+
+ )} + {/* Installation hint when not installed - inside card */} + {!status?.opencodeCli?.installed && !statusLoading && ( +
+ + {showOpencodeHint && ( +
+

+ Install globally (FREE tier available): +

+ + curl -fsSL https://opencode.ai/install | bash + + + + View documentation + +
+ )} +
+ )} +
+ + {/* Grok CLI Provider */} +
+
+
+ +
+
+

grok

+ + GROK_API_KEY + + {status?.grokCli?.installed ? ( + + installed + + ) : ( + + not installed + + )} +
+

xAI Grok CLI (web + X search)

+
+
+ +
+ {/* Installation hint when not installed - inside card */} + {!status?.grokCli?.installed && !statusLoading && ( +
+ + {showGrokHint && ( +
+

+ Install globally (requires xAI API key): +

+ + npm install -g @vibe-kit/grok-cli + + + + View documentation + +
+ )} +
+ )} +
+
+
+
+ + {/* Footer */} +
+ +
+ + ); +} + +// Global Env Tab Content Component +interface GlobalEnvContentProps { + config: GlobalEnvConfig | null; + loading: boolean; + saving: boolean; + error: string | null; + success: boolean; + newEnvKey: string; + newEnvValue: string; + setNewEnvKey: (v: string) => void; + setNewEnvValue: (v: string) => void; + toggleGlobalEnv: () => void; + addEnvVar: () => void; + removeEnvVar: (key: string) => void; + updateEnvValue: (key: string, value: string) => void; + fetchGlobalEnvConfig: () => void; + fetchRawConfig: () => void; +} + +function GlobalEnvContent({ + config, + loading, + saving, + error, + success, + newEnvKey, + newEnvValue, + setNewEnvKey, + setNewEnvValue, + toggleGlobalEnv, + addEnvVar, + removeEnvVar, + fetchGlobalEnvConfig, + fetchRawConfig, +}: GlobalEnvContentProps) { + if (loading) { + return ( +
+
+ + Loading... +
+
+ ); + } + + return ( + <> + {/* Toast-style alerts */} +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + Saved +
+ )} +
+ + {/* Scrollable Content */} + +
+ {/* Description */} +

+ Environment variables injected into all non-Claude subscription profiles (gemini, codex, + agy, copilot, etc.) +

+ + {/* Enable/Disable Toggle */} +
+
+

+ {config?.enabled ? 'Global Env enabled' : 'Global Env disabled'} +

+

+ {config?.enabled + ? 'Env vars will be injected into third-party profiles' + : 'Env vars will not be injected'} +

+
+ +
+ + {/* Current Environment Variables */} +
+

Environment Variables

+ + {config?.env && Object.keys(config.env).length > 0 ? ( +
+ {Object.entries(config.env).map(([key, value]) => ( +
+ {key} + = + {value} + +
+ ))} +
+ ) : ( +
+

No environment variables configured

+
+ )} + + {/* Add New Variable */} +
+

Add New Variable

+
+ setNewEnvKey(e.target.value.toUpperCase())} + placeholder="KEY_NAME" + className="flex-1 font-mono text-sm h-9" + disabled={saving} + /> + = + setNewEnvValue(e.target.value)} + placeholder="value" + className="flex-1 font-mono text-sm h-9" + disabled={saving} + /> + +
+
+ + {/* Common Variables Quick Add */} +
+

Quick Add Common Variables

+
+ {[ + { key: 'DISABLE_BUG_COMMAND', value: '1' }, + { key: 'DISABLE_ERROR_REPORTING', value: '1' }, + { key: 'DISABLE_TELEMETRY', value: '1' }, + ].map( + ({ key, value }) => + !config?.env?.[key] && ( + + ) + )} + {config?.env && + ['DISABLE_BUG_COMMAND', 'DISABLE_ERROR_REPORTING', 'DISABLE_TELEMETRY'].every( + (k) => config.env[k] + ) && ( + + All common variables are configured + + )} +
+
+
+
+
+ + {/* Footer */} +
+ +
+ + ); +} + +// Proxy Tab Content Component +interface ProxyContentProps { + config: CliproxyServerConfig | null; + loading: boolean; + saving: boolean; + error: string | null; + success: boolean; + testResult: RemoteProxyStatus | null; + testing: boolean; + saveCliproxyServerConfig: (updates: Partial) => void; + handleTestConnection: () => void; + fetchCliproxyServerConfig: () => void; + fetchRawConfig: () => void; +} + +function ProxyContent({ + config, + loading, + saving, + error, + success, + testResult, + testing, + saveCliproxyServerConfig, + handleTestConnection, + fetchCliproxyServerConfig, + fetchRawConfig, +}: ProxyContentProps) { + // Memoized default config to avoid recreation + const defaultRemote = { + enabled: false, + host: '', + port: 8317, + protocol: 'http' as const, + auth_token: '', + }; + const defaultFallback = { enabled: true, auto_start: true }; + const defaultLocal = { port: 8317, auto_start: true }; + + // Sync local state with config (using refs to avoid lint warnings) + const hostInput = config?.remote.host ?? ''; + const portInput = (config?.remote.port ?? 8317).toString(); + const authTokenInput = config?.remote.auth_token ?? ''; + const localPortInput = (config?.local.port ?? 8317).toString(); + + // Track edited values separately + const [editedHost, setEditedHost] = useState(null); + const [editedPort, setEditedPort] = useState(null); + const [editedAuthToken, setEditedAuthToken] = useState(null); + const [editedLocalPort, setEditedLocalPort] = useState(null); + + // Get display values (edited or from config) + const displayHost = editedHost ?? hostInput; + const displayPort = editedPort ?? portInput; + const displayAuthToken = editedAuthToken ?? authTokenInput; + const displayLocalPort = editedLocalPort ?? localPortInput; + + if (loading) { + return ( +
+
+ + Loading... +
+
+ ); + } + + const isRemoteMode = config?.remote.enabled ?? false; + const remoteConfig = config?.remote ?? defaultRemote; + const fallbackConfig = config?.fallback ?? defaultFallback; + const localConfig = config?.local ?? defaultLocal; + + // Save functions for blur events + const saveHost = () => { + const value = editedHost ?? displayHost; + if (value !== config?.remote.host) { + saveCliproxyServerConfig({ remote: { ...remoteConfig, host: value } }); + } + setEditedHost(null); + }; + + const savePort = () => { + const port = parseInt(editedPort ?? displayPort, 10); + if (!isNaN(port) && port !== config?.remote.port) { + saveCliproxyServerConfig({ remote: { ...remoteConfig, port } }); + } + setEditedPort(null); + }; + + const saveAuthToken = () => { + const value = editedAuthToken ?? displayAuthToken; + if (value !== config?.remote.auth_token) { + saveCliproxyServerConfig({ remote: { ...remoteConfig, auth_token: value } }); + } + setEditedAuthToken(null); + }; + + const saveLocalPort = () => { + const port = parseInt(editedLocalPort ?? displayLocalPort, 10); + if (!isNaN(port) && port !== config?.local.port) { + saveCliproxyServerConfig({ local: { ...localConfig, port } }); + } + setEditedLocalPort(null); + }; + + return ( + <> + {/* Toast-style alerts */} +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + Saved +
+ )} +
+ + {/* Scrollable Content */} + +
+ {/* Description */} +

+ Configure local or remote CLIProxyAPI connection for proxy-based profiles +

+ + {/* Mode Toggle - Card based selection */} +
+

Connection Mode

+
+ {/* Local Mode Card */} + + + {/* Remote Mode Card */} + +
+
+ + {/* Remote Settings - Show when remote mode is enabled */} + {isRemoteMode && ( +
+

+ + Remote Server Configuration +

+ + {/* Host */} +
+ + setEditedHost(e.target.value)} + onBlur={saveHost} + placeholder="192.168.1.100 or proxy.example.com" + className="font-mono" + disabled={saving} + /> +
+ + {/* Port and Protocol */} +
+
+ + setEditedPort(e.target.value)} + onBlur={savePort} + placeholder="8317" + className="font-mono" + disabled={saving} + /> +
+
+ + +
+
+ + {/* Auth Token */} +
+ + setEditedAuthToken(e.target.value)} + onBlur={saveAuthToken} + placeholder="Bearer token for authentication" + className="font-mono" + disabled={saving} + /> +
+ + {/* Test Connection */} +
+ + + {/* Test Result */} + {testResult && ( +
+
+ {testResult.reachable ? ( + <> + + + Connected ({testResult.latencyMs}ms) + + + ) : ( + <> + + + {testResult.error || 'Connection failed'} + + + )} +
+
+ )} +
+
+ )} + + {/* Fallback Settings */} +
+

Fallback Settings

+
+ {/* Enable Fallback */} +
+
+

Enable fallback to local

+

+ Use local proxy if remote is unreachable +

+
+ + saveCliproxyServerConfig({ fallback: { ...fallbackConfig, enabled: checked } }) + } + disabled={saving || !isRemoteMode} + /> +
+ + {/* Auto-start on fallback */} +
+
+

Auto-start local proxy

+

+ Automatically start local proxy on fallback +

+
+ + saveCliproxyServerConfig({ + fallback: { ...fallbackConfig, auto_start: checked }, + }) + } + disabled={saving || !isRemoteMode || !config?.fallback.enabled} + /> +
+
+
+ + {/* Local Proxy Settings */} +
+

Local Proxy

+
+ {/* Port */} +
+ + setEditedLocalPort(e.target.value)} + onBlur={saveLocalPort} + placeholder="8317" + className="font-mono max-w-32" + disabled={saving} + /> +
+ + {/* Auto-start */} +
+
+

Auto-start

+

+ Start local proxy automatically when needed +

+
+ + saveCliproxyServerConfig({ local: { ...localConfig, auto_start: checked } }) + } + disabled={saving} + /> +
+
+
+
+
+ + {/* Footer */} +
+ +
+ + ); +}