Merge pull request #147 from kaitranntt/dev

feat: remote proxy mode, error log viewer, and CI improvements
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-19 03:27:07 -05:00
committed by GitHub
54 changed files with 5401 additions and 3139 deletions
+41 -82
View File
@@ -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
+20 -1
View File
@@ -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"
+120
View File
@@ -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;
-23
View File
@@ -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"]
}]
]
}
+72
View File
@@ -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)
+1
View File
@@ -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) |
-1
View File
@@ -1 +0,0 @@
6.5.0
+56 -39
View File
@@ -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=="],
}
}
-808
View File
@@ -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 ""
-927
View File
@@ -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 ""
-99
View File
@@ -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 ""
-95
View File
@@ -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!"
+6 -3
View File
@@ -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",
+214
View File
@@ -0,0 +1,214 @@
/**
* Send Release Notification to Discord using Embeds
*
* Usage:
* node send-discord-release.cjs <type> <webhook-url>
*
* 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 <type> <webhook-url>');
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);
}
-41
View File
@@ -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');
}
}
};
-15
View File
@@ -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`);
+18 -38
View File
@@ -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 });
}
};
+18 -5
View File
@@ -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<void> {
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<void> {
// 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
};
+56 -19
View File
@@ -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<string> {
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<CliproxyUpdateCheckResult> {
const manager = new BinaryManager();
return manager.checkForUpdates();
}
/**
* Get path to version pin file
* @returns Absolute path to .version-pin file
+245 -135
View File
@@ -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<string>((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 <account> 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 <name> 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');
};
+78 -9
View File
@@ -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<string, string> {
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<string, string> {
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<string, string> = {
...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;
}
+279
View File
@@ -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'
);
}
+236
View File
@@ -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<RemoteProxyStatus> {
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<string, string> = {
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<Response>((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<RemoteProxyStatus> {
return checkRemoteProxy(config);
}
+5
View File
@@ -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
+86
View File
@@ -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<CliproxyErrorLog[] | null> {
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<string | null> {
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)
+25
View File
@@ -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;
}
+199 -9
View File
@@ -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<void> {
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<void> {
// 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<string>((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<void> {
// Check if logs directory exists
if (!fs.existsSync(logsDir)) {
console.log(info('No CLIProxy logs found.'));
+19
View File
@@ -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 <host>', 'Remote proxy hostname/IP'],
['--proxy-port <port>', 'Proxy port (default: 8317)'],
['--proxy-protocol <proto>', 'Protocol: http or https (default: http)'],
['--proxy-auth-token <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')}`);
+26 -139
View File
@@ -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<void> {
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);
});
}
+71
View File
@@ -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>): 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 },
};
}
+106 -2
View File
@@ -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<string, string>;
}
/**
* 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<string, string> = {
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 },
};
}
+7 -1
View File
@@ -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,
};
+1 -65
View File
@@ -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
*/
+4
View File
@@ -51,6 +51,10 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
const { usageRoutes } = await import('./usage-routes');
app.use('/api/usage', usageRoutes);
// CLIProxy server settings routes (Phase 5)
const cliproxyServerRoutes = (await import('./routes/proxy-routes')).default;
app.use('/api/cliproxy-server', cliproxyServerRoutes);
// Dev mode: use Vite middleware for HMR
if (options.dev) {
const { createServer: createViteServer } = await import('vite');
+157 -2
View File
@@ -22,7 +22,10 @@ import {
fetchCliproxyStats,
fetchCliproxyModels,
isCliproxyRunning,
fetchCliproxyErrorLogs,
fetchCliproxyErrorLogContent,
} from '../cliproxy/stats-fetcher';
import { getCliproxyWritablePath } from '../cliproxy/config-generator';
import {
listOpenAICompatProviders,
getOpenAICompatProvider,
@@ -41,8 +44,9 @@ import {
} from '../cliproxy/account-manager';
import type { CLIProxyProvider } from '../cliproxy/types';
import { getClaudeEnvVars } from '../cliproxy/config-generator';
import { getProxyStatus as getProxyProcessStatus } from '../cliproxy/session-tracker';
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../cliproxy/session-tracker';
import { ensureCliproxyService } from '../cliproxy/service-manager';
import { checkCliproxyUpdate } from '../cliproxy/binary-manager';
// Unified config imports
import {
hasUnifiedConfig,
@@ -1386,6 +1390,32 @@ apiRoutes.post('/cliproxy/proxy-start', async (_req: Request, res: Response): Pr
}
});
/**
* POST /api/cliproxy/proxy-stop - Stop the CLIProxy service
* Returns: { stopped, pid?, sessionCount?, error? }
*/
apiRoutes.post('/cliproxy/proxy-stop', (_req: Request, res: Response): void => {
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<void> => {
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<string, CliproxyModel[]>, 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<void> => {
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<void> => {
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 });
}
});
+96
View File
@@ -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<CliproxyServerConfig>;
// 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;
@@ -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);
});
});
});
@@ -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');
});
});
});
@@ -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;
}
});
});
});
});
@@ -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;
}
});
});
});
});
@@ -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
</div>
)}
<div className="flex-1 overflow-hidden px-6 pb-6 pt-4">
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
<div className="h-full border rounded-md overflow-hidden bg-background">
<CodeEditor
value={rawJsonContent}
@@ -553,6 +554,12 @@ export function ProviderEditor({
/>
</div>
</div>
{/* Global Env Indicator */}
<div className="mx-6 mb-4">
<div className="border rounded-md overflow-hidden">
<GlobalEnvIndicator profileEnv={settings?.env} />
</div>
</div>
</div>
</Suspense>
);
@@ -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
</div>
)}
<div className="flex-1 overflow-hidden px-6 pb-6 pt-4">
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
<div className="h-full border rounded-md overflow-hidden bg-background">
<CodeEditor
value={rawJsonContent}
@@ -736,6 +737,12 @@ export function CopilotConfigForm() {
/>
</div>
</div>
{/* Global Env Indicator */}
<div className="mx-6 mb-4">
<div className="border rounded-md overflow-hidden">
<GlobalEnvIndicator profileEnv={rawSettings?.settings?.env} />
</div>
</div>
</div>
</Suspense>
);
+617
View File
@@ -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 (
<button
onClick={onClick}
className={cn(
'px-2.5 py-1.5 text-xs font-medium rounded transition-colors flex items-center gap-1.5',
active
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
)}
>
{Icon && <Icon className="w-3.5 h-3.5" />}
{children}
</button>
);
}
/** Status badge component */
function StatusBadge({ code }: { code: number }) {
const colorClass = getStatusColor(code);
return (
<span
className={cn(
'inline-flex items-center justify-center min-w-[36px] px-2 py-0.5 rounded text-xs font-bold',
'bg-current/10 border border-current/20',
colorClass
)}
>
{code}
</span>
);
}
/** Overview tab content */
function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
return (
<div className="p-4 space-y-4">
{/* Status row */}
<div className="flex items-center gap-3">
<StatusBadge code={parsed.statusCode} />
<span className="text-sm font-medium">{parsed.statusText}</span>
<span className="text-xs text-muted-foreground px-2 py-0.5 rounded bg-muted/50">
{getErrorTypeLabel(parsed.errorType)}
</span>
</div>
{/* Key metrics grid */}
<div className="grid grid-cols-4 gap-3 text-xs">
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Method</div>
<div className="font-medium">{parsed.method || 'N/A'}</div>
</div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Provider</div>
<div className="font-medium">{parsed.provider || 'N/A'}</div>
</div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Version</div>
<div className="font-medium">{parsed.version || 'N/A'}</div>
</div>
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
<div className="text-muted-foreground mb-1">Endpoint</div>
<div className="font-medium truncate" title={parsed.endpoint}>
{parsed.endpoint || 'N/A'}
</div>
</div>
</div>
{/* URL */}
<div className="text-xs">
<div className="text-muted-foreground mb-1.5">URL</div>
<div className="font-mono p-2.5 rounded bg-muted/30 border border-border/50 break-all leading-relaxed">
{parsed.url || 'N/A'}
</div>
</div>
{/* Timestamp */}
<div className="text-xs">
<div className="text-muted-foreground mb-1.5">Timestamp</div>
<div className="font-mono">{parsed.timestamp || 'N/A'}</div>
</div>
{/* Suggestion based on error type */}
{parsed.errorType !== 'unknown' && (
<div className="flex items-start gap-3 p-3 rounded bg-blue-500/10 border border-blue-500/20 text-xs">
<Info className="w-4 h-4 mt-0.5 text-blue-500 shrink-0" />
<div className="text-blue-500/90 leading-relaxed">
{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.'}
</div>
</div>
)}
</div>
);
}
/** Headers tab content */
function HeadersTab({ headers }: { headers: Record<string, string> }) {
const entries = Object.entries(headers);
if (entries.length === 0) {
return <div className="p-4 text-xs text-muted-foreground">No headers available</div>;
}
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-1">
{entries.map(([key, value]) => (
<div
key={key}
className="flex gap-3 text-xs font-mono py-1.5 border-b border-border/30 last:border-0"
>
<span className="text-muted-foreground shrink-0 min-w-[140px]">{key}:</span>
<span className="break-all">{value}</span>
</div>
))}
</div>
</ScrollArea>
);
}
/** JSON/Body tab content */
function BodyTab({ content, label }: { content: string; label: string }) {
if (!content || content.trim() === '') {
return <div className="p-4 text-xs text-muted-foreground">No {label.toLowerCase()} body</div>;
}
// 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 (
<ScrollArea className="h-full">
<pre
className={cn(
'p-4 text-xs font-mono whitespace-pre-wrap break-all leading-relaxed',
isJson
? 'text-emerald-700 dark:text-green-400'
: 'text-zinc-700 dark:text-muted-foreground'
)}
>
{formatted}
</pre>
</ScrollArea>
);
}
/** Raw tab content */
function RawTab({ content }: { content: string }) {
return (
<ScrollArea className="h-full">
<pre className="p-4 text-xs font-mono text-zinc-700 dark:text-muted-foreground whitespace-pre-wrap break-all leading-relaxed">
{content}
</pre>
</ScrollArea>
);
}
/** Log content panel with tabs */
function LogContentPanel({ name, absolutePath }: { name: string | null; absolutePath?: string }) {
const [activeTab, setActiveTab] = useState<TabType>('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 (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-3">
<Terminal className="w-10 h-10 mx-auto opacity-40" />
<p className="text-sm">Select a log to view details</p>
</div>
</div>
);
}
// Loading state
if (isLoading) {
return (
<div className="flex-1 p-6 space-y-3">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-5 w-5/6" />
<Skeleton className="h-5 w-2/3" />
</div>
);
}
// Error or no content
if (error || !content || !parsed) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<p className="text-sm">Failed to load log content</p>
</div>
);
}
return (
<div className="flex-1 flex flex-col min-w-0 h-full">
{/* Header with status */}
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center justify-between gap-3 shrink-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<StatusBadge code={parsed.statusCode} />
<span className="text-xs font-semibold truncate text-foreground">
{parsed.provider}/{parsed.endpoint || 'unknown'}
</span>
{/* Copy Absolute Path Button */}
{name && (
<CopyButton
value={absolutePath || name}
label="Copy absolute path"
size="icon-sm"
className="ml-1 text-muted-foreground hover:text-foreground opacity-50 hover:opacity-100 transition-opacity"
/>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{/* Copy Raw Content Button */}
{content && (
<CopyButton
value={content}
label="Copy raw log content"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
/>
)}
<span className="text-[10px] text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded border border-border/50">
{parsed.method}
</span>
</div>
</div>
{/* Tabs */}
<div className="px-3 py-2 border-b border-border flex items-center gap-1 bg-muted/10 shrink-0 overflow-x-auto">
<TabButton
active={activeTab === 'overview'}
onClick={() => setActiveTab('overview')}
icon={Info}
>
Overview
</TabButton>
<TabButton
active={activeTab === 'headers'}
onClick={() => setActiveTab('headers')}
icon={Code}
>
Headers
</TabButton>
<TabButton
active={activeTab === 'request'}
onClick={() => setActiveTab('request')}
icon={ArrowUpRight}
>
Request
</TabButton>
<TabButton
active={activeTab === 'response'}
onClick={() => setActiveTab('response')}
icon={ArrowDownLeft}
>
Response
</TabButton>
<TabButton active={activeTab === 'raw'} onClick={() => setActiveTab('raw')} icon={FileText}>
Raw
</TabButton>
</div>
{/* Tab content */}
<div className="flex-1 overflow-hidden bg-card/30">
{activeTab === 'overview' && <OverviewTab parsed={parsed} />}
{activeTab === 'headers' && <HeadersTab headers={parsed.requestHeaders} />}
{activeTab === 'request' && <BodyTab content={parsed.requestBody} label="Request" />}
{activeTab === 'response' && <BodyTab content={parsed.responseBody} label="Response" />}
{activeTab === 'raw' && <RawTab content={content} />}
</div>
</div>
);
}
/** 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 (
<button
onClick={onClick}
className={cn(
'w-full px-3 py-2.5 flex items-start gap-3 text-left transition-colors',
'hover:bg-muted/40 border-l-[3px]',
isSelected ? 'bg-muted/50 border-l-amber-500' : 'border-l-transparent'
)}
>
{/* Provider Icon */}
<ProviderIcon
provider={parsed.provider}
size={24}
withBackground={true}
className="shrink-0 mt-0.5"
/>
<div className="flex-1 min-w-0 space-y-1">
{/* Provider / Endpoint */}
<div className="flex flex-col gap-0.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-foreground truncate">
{parsed.provider}
</span>
<span
className={cn(
'text-[9px] px-1 rounded border',
isSelected
? 'bg-amber-500/10 text-amber-600 border-amber-500/20'
: 'bg-muted border-border text-muted-foreground'
)}
>
LOG
</span>
</div>
<span
className="text-[11px] text-muted-foreground truncate font-medium"
title={parsed.endpoint}
>
{parsed.endpoint}
</span>
</div>
{/* Meta row: time + size */}
<div className="flex items-center gap-3 text-[10px] text-muted-foreground/80 mt-1">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatRelativeTime(modified)}
</span>
<span className="flex items-center gap-1">
<FileText className="w-3 h-3" />
{formatBytes(size)}
</span>
</div>
</div>
</button>
);
}
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<HTMLDivElement>(null);
const scrollIntervalRef = useRef<NodeJS.Timeout | null>(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<string | null>(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 (
<div className="rounded-xl border border-border overflow-hidden font-mono text-sm bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm h-[500px]">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-20" />
</div>
<div className="p-4 space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
</div>
);
}
if (!logs || logs.length === 0) return null;
const errorCount = logs.length;
return (
<div
ref={containerRef}
className="rounded-xl border border-border overflow-hidden font-mono text-sm text-foreground bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm flex flex-col shadow-sm transition-[height] duration-0 ease-linear relative group/container"
style={{ height }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-gradient-to-r from-amber-500/10 via-transparent to-transparent dark:from-amber-500/15 shrink-0">
<div className="flex items-center gap-2.5">
<AlertTriangle className="w-4 h-4 text-amber-500" />
<span className="text-sm font-semibold tracking-tight">Error Logs</span>
<span className="text-xs text-muted-foreground ml-1">
{errorCount} failed request{errorCount !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileWarning className="w-3.5 h-3.5" />
<span>CLIProxy Diagnostics</span>
</div>
</div>
{/* Resizable Panel Layout */}
<div className="flex-1 min-h-0">
<PanelGroup direction="horizontal">
{/* Left Panel: Log List */}
<Panel defaultSize={30} minSize={20} maxSize={50} className="flex flex-col min-w-0">
<ScrollArea className="h-full">
<div className="divide-y divide-border/50">
{logs.slice(0, 50).map((log) => (
<ErrorLogItem
key={log.name}
name={log.name}
size={log.size}
modified={log.modified}
isSelected={effectiveSelection === log.name}
onClick={() => setSelectedLog(log.name)}
/>
))}
</div>
{logs.length > 50 && (
<div className="px-3 py-3 text-center text-[10px] text-muted-foreground border-t border-border/50">
Showing 50 of {logs.length} logs
</div>
)}
</ScrollArea>
</Panel>
{/* Resize Handle */}
<PanelResizeHandle className="w-[1px] bg-border hover:bg-primary/50 transition-colors flex items-center justify-center group relative z-10 w-2 -ml-1 flex items-center justify-center outline-none">
<div className="w-[1px] h-full bg-border group-hover:bg-primary/50 transition-colors" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-4 h-8 rounded-sm flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-muted border border-border">
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
</PanelResizeHandle>
{/* Right Panel: Log Content */}
<Panel className="flex flex-col min-w-0 bg-background/50">
<LogContentPanel name={effectiveSelection} absolutePath={selectedAbsolutePath} />
</Panel>
</PanelGroup>
</div>
{/* Use standard footer if error, otherwise show resize handle */}
{error ? (
<div className="px-4 py-2 border-t border-border text-xs text-destructive bg-destructive/5 shrink-0">
{error.message}
</div>
) : (
<div
className="h-2 bg-border/10 border-t border-border/30 hover:bg-primary/10 transition-colors cursor-row-resize flex items-center justify-center group/handle shrink-0"
onMouseDown={startResizing}
>
<GripHorizontal className="w-8 h-3 text-border group-hover:text-primary/50 transition-colors" />
</div>
)}
</div>
);
}
+132
View File
@@ -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<string, string>;
}
interface GlobalEnvIndicatorProps {
/** Current profile's env vars (to show which are overridden) */
profileEnv?: Record<string, string>;
}
export function GlobalEnvIndicator({ profileEnv = {} }: GlobalEnvIndicatorProps) {
const [config, setConfig] = useState<GlobalEnvConfig | null>(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 (
<div className="border-t bg-muted/20">
{/* Header - clickable to expand */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full px-4 py-2 flex items-center gap-2 hover:bg-muted/30 transition-colors"
>
<Info className="w-4 h-4 text-blue-500" />
<span className="text-xs text-muted-foreground flex-1 text-left">
<span className="font-medium text-foreground">{injectedKeys.length}</span> global env var
{injectedKeys.length !== 1 ? 's' : ''} will be injected at runtime
{overriddenKeys.length > 0 && (
<span className="text-amber-600 dark:text-amber-400 ml-1">
({overriddenKeys.length} overridden by profile)
</span>
)}
</span>
{expanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</button>
{/* Expanded content */}
{expanded && (
<div className="px-4 pb-3 space-y-2">
{/* Injected vars */}
{injectedKeys.length > 0 && (
<div className="space-y-1">
{injectedKeys.map((key) => (
<div
key={key}
className="flex items-center gap-2 text-xs font-mono bg-green-500/10 text-green-700 dark:text-green-400 px-2 py-1 rounded"
>
<span className="text-green-500">+</span>
<span className="truncate">
{key}={envVars[key]}
</span>
</div>
))}
</div>
)}
{/* Overridden vars (profile takes precedence) */}
{overriddenKeys.length > 0 && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Skipped (profile already defines):</p>
{overriddenKeys.map((key) => (
<div
key={key}
className="flex items-center gap-2 text-xs font-mono bg-amber-500/10 text-amber-700 dark:text-amber-400 px-2 py-1 rounded"
>
<span className="text-amber-500">~</span>
<span className="truncate">{key}</span>
</div>
))}
</div>
)}
{/* Link to settings */}
<div className="pt-2 border-t border-border/50">
<Button variant="ghost" size="sm" asChild className="h-7 text-xs gap-1.5 -ml-2">
<Link to="/settings?tab=globalenv">
<Settings2 className="w-3.5 h-3.5" />
Configure in Settings
<ExternalLink className="w-3 h-3 opacity-50" />
</Link>
</Button>
</div>
</div>
)}
</div>
);
}
+8 -1
View File
@@ -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
</div>
)}
<div className="flex-1 overflow-hidden px-6 pb-6 pt-4">
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
<div className="h-full border rounded-md overflow-hidden bg-background">
<CodeEditor
value={rawJsonContent}
@@ -422,6 +423,12 @@ export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) {
/>
</div>
</div>
{/* Global Env Indicator */}
<div className="mx-6 mb-4">
<div className="border rounded-md overflow-hidden">
<GlobalEnvIndicator profileEnv={settings?.env} />
</div>
</div>
</div>
</Suspense>
);
+115 -19
View File
@@ -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 (
<div
@@ -47,6 +76,16 @@ export function ProxyStatusWidget() {
)}
/>
<span className="text-sm font-medium">CLIProxy Service</span>
{hasUpdate && (
<Badge
variant="secondary"
className="text-[10px] h-4 px-1.5 gap-0.5 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
title={`Update: v${updateCheck?.currentVersion} -> v${updateCheck?.latestVersion}`}
>
<ArrowUp className="w-2.5 h-2.5" />
Update
</Badge>
)}
</div>
<div className="flex items-center gap-1">
@@ -61,21 +100,66 @@ export function ProxyStatusWidget() {
</div>
{isRunning && status ? (
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">Port {status.port}</span>
{status.sessionCount !== undefined && status.sessionCount > 0 && (
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''}
</span>
)}
{status.startedAt && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatUptime(status.startedAt)}
</span>
)}
</div>
<>
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">Port {status.port}</span>
{status.sessionCount !== undefined && status.sessionCount > 0 && (
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''}
</span>
)}
{status.startedAt && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatUptime(status.startedAt)}
</span>
)}
</div>
{/* Control buttons when running */}
<div className="mt-2 flex items-center gap-2">
<Button
variant={hasUpdate ? 'default' : 'outline'}
size="sm"
className={cn(
'h-7 text-xs gap-1 flex-1',
hasUpdate &&
'bg-sidebar-accent hover:bg-sidebar-accent/90 text-sidebar-accent-foreground'
)}
onClick={handleRestart}
disabled={isActioning}
title={
hasUpdate
? `Restart to update: v${updateCheck?.currentVersion} -> v${updateCheck?.latestVersion}`
: 'Restart CLIProxy service'
}
>
{isActioning ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : hasUpdate ? (
<ArrowUp className="w-3 h-3" />
) : (
<RotateCw className="w-3 h-3" />
)}
{hasUpdate ? 'Update' : 'Restart'}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/30"
onClick={() => stopProxy.mutate()}
disabled={isActioning}
title="Stop CLIProxy service"
>
{stopProxy.isPending ? (
<RefreshCw className="w-3 h-3 animate-spin" />
) : (
<Square className="w-3 h-3" />
)}
Stop
</Button>
</div>
</>
) : (
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-muted-foreground">Not running</span>
@@ -95,6 +179,18 @@ export function ProxyStatusWidget() {
</Button>
</div>
)}
{/* Version sync indicator */}
{updateCheck?.currentVersion && (
<div className="mt-2 pt-2 border-t border-muted flex items-center justify-between text-[10px] text-muted-foreground/70">
<span>v{updateCheck.currentVersion}</span>
{updateCheck.checkedAt && (
<span title={new Date(updateCheck.checkedAt).toLocaleString()}>
Synced {formatTimeAgo(updateCheck.checkedAt)}
</span>
)}
</div>
)}
</div>
);
}
+8 -9
View File
@@ -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<typeof buttonVariants>['variant'];
size?: VariantProps<typeof buttonVariants>['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({
<Button
size={size}
variant={variant}
className={cn(
'h-6 w-6 relative z-10 text-foreground/70 hover:text-foreground',
className
)}
className={cn('relative z-10 text-muted-foreground hover:text-foreground', className)}
onClick={(e) => {
e.stopPropagation();
onCopy();
}}
>
{hasCopied ? (
<Check className="h-3 w-3 text-green-500" />
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Copy className="h-3 w-3" />
<Copy className="h-3.5 w-3.5" />
)}
<span className="sr-only">{label}</span>
</Button>
+59
View File
@@ -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<CliproxyErrorLog[]> {
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<string> {
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
});
}
+33
View File
@@ -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)
});
}
+99
View File
@@ -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<ProxyProcessStatus>('/cliproxy/proxy-status'),
proxyStart: () => request<ProxyStartResult>('/cliproxy/proxy-start', { method: 'POST' }),
proxyStop: () => request<ProxyStopResult>('/cliproxy/proxy-stop', { method: 'POST' }),
updateCheck: () => request<CliproxyUpdateCheckResult>('/cliproxy/update-check'),
// Stats and models for Overview tab
stats: () => request<{ usage: Record<string, unknown> }>('/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<string> => {
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<CliproxyServerConfig>('/cliproxy-server'),
/** Update cliproxy server configuration */
update: (config: Partial<CliproxyServerConfig>) =>
request<CliproxyServerConfig>('/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<RemoteProxyStatus>('/cliproxy-server/test', {
method: 'POST',
body: JSON.stringify(params),
}),
},
};
+328
View File
@@ -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<string, string>;
requestBody: string;
responseHeaders: Record<string, string>;
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<string, string> {
const headers: Record<string, string> = {};
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<number, string> = {
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<string, string> = {
rate_limit: 'Rate Limited',
auth: 'Auth Error',
not_found: 'Not Found',
server: 'Server Error',
timeout: 'Timeout',
unknown: 'Error',
};
return labels[type] || 'Error';
}
+1 -1
View File
@@ -227,7 +227,7 @@ export function CliproxyPage() {
return (
<div className="h-[calc(100vh-100px)] flex">
{/* Left Sidebar */}
<div className="w-64 border-r flex flex-col bg-muted/30">
<div className="w-80 border-r flex flex-col bg-muted/30">
{/* Header */}
<div className="p-4 border-b bg-background">
<div className="flex items-center justify-between mb-1">
+4
View File
@@ -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 */}
<AuthMonitor />
{/* Error Logs Monitor - shows only when there are errors */}
<ErrorLogsMonitor />
</div>
);
}
+1314 -339
View File
File diff suppressed because it is too large Load Diff