Merge pull request #754 from kaitranntt/dev

feat(release): promote dev to main
This commit is contained in:
Kai (Tam Nhu) Tran
2026-03-17 16:35:45 -04:00
committed by GitHub
114 changed files with 7331 additions and 3139 deletions
+40
View File
@@ -0,0 +1,40 @@
# CCS Code of Conduct
CCS is a technical project. Keep discussion respectful, constructive, and focused on improving the work.
## Expected Behavior
- Be respectful in issues, pull requests, reviews, and discussions.
- Assume good intent and ask clarifying questions before escalating.
- Give feedback that is specific, technical, and actionable.
- Be patient with contributors who are new to the codebase or toolchain.
- Respect maintainers' time by keeping reports reproducible and well scoped.
## Unacceptable Behavior
- Harassment, discrimination, or hate speech
- Personal attacks, insults, or hostile dogpiling
- Publishing private information, credentials, logs, or screenshots that expose sensitive data
- Spam, repeated derailment, or intentionally disruptive behavior
- Sexualized language or unwelcome sexual attention
## Scope
This applies to project spaces, including:
- GitHub issues
- Pull requests and review comments
- Discussions
- Any other repository-managed collaboration channel
## Enforcement
Maintainers may edit or remove content, lock conversations, close threads, reject contributions, or block participants when needed to protect the project and contributors.
For non-sensitive concerns, open a GitHub Discussion or issue.
For sensitive concerns, do not post details publicly. Ask a maintainer for a private reporting path first and keep the initial message minimal.
## Practical Rule
Critique code, behavior, and decisions. Do not attack people.
+87
View File
@@ -0,0 +1,87 @@
name: Bug report
description: Report a reproducible problem in the CLI, dashboard, config flow, or packaging.
title: "bug: "
labels:
- bug
body:
- type: markdown
attributes:
value: |
Thanks for reporting this. Use as much of this template as you can.
A partial but useful report is better than no report.
- type: dropdown
id: area
attributes:
label: Affected area
description: Pick the closest area.
options:
- CLI runtime
- Dashboard UI
- Config or auth flow
- Provider integration
- Install or packaging
- Documentation
- type: textarea
id: summary
attributes:
label: What broke?
description: Brief summary of the problem.
placeholder: Running `ccs config --host 0.0.0.0` prints the wrong reachable URL on macOS.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction steps
description: Exact steps are ideal, but rough steps are still helpful.
placeholder: |
1. Run `ccs ...`
2. Open ...
3. Observe ...
- type: textarea
id: expected
attributes:
label: Expected behavior
- type: textarea
id: actual
attributes:
label: Actual behavior
- type: input
id: version
attributes:
label: CCS version
description: Output of `ccs --version`, if you have it
placeholder: 7.54.0
- type: dropdown
id: os
attributes:
label: Operating system
options:
- macOS
- Linux
- Windows
- Other
- type: textarea
id: environment
attributes:
label: Environment details
description: Include Node.js version, Bun version, shell, terminal, and anything else relevant.
placeholder: |
Node.js:
Bun:
Shell:
Terminal:
- type: textarea
id: logs
attributes:
label: Logs, screenshots, or terminal output
description: Redact tokens, cookies, email addresses, and any private config before posting.
render: shell
- type: checkboxes
id: checks
attributes:
label: Before submitting
options:
- label: I searched existing issues first.
- label: I removed secrets and private data from logs/screenshots.
- label: I can still reproduce this on the latest released or dev build.
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Questions and discussion
url: https://github.com/kaitranntt/ccs/discussions
about: Use Discussions for open-ended questions, support requests, and idea shaping.
+45
View File
@@ -0,0 +1,45 @@
name: Documentation improvement
description: Report missing, outdated, or confusing docs.
title: "docs: "
labels:
- documentation
body:
- type: markdown
attributes:
value: |
Small or rough documentation reports are welcome. You do not need to fill every field.
- type: dropdown
id: area
attributes:
label: Documentation area
options:
- README
- CONTRIBUTING guide
- Local docs in docs/
- Command help output
- Dashboard copy or labels
- Other
- type: input
id: location
attributes:
label: File or page
description: Path or URL if you know it.
placeholder: README.md or docs/cursor-integration.md
- type: textarea
id: problem
attributes:
label: What is wrong or unclear?
placeholder: The guide still says to use an old command/path that no longer exists.
validations:
required: true
- type: textarea
id: suggestion
attributes:
label: Suggested improvement
placeholder: Replace it with ...
- type: checkboxes
id: checks
attributes:
label: Before submitting
options:
- label: I checked whether this is already covered elsewhere in the repo.
@@ -0,0 +1,64 @@
name: Feature request
description: Suggest a focused improvement for the CLI, dashboard, or contributor workflow.
title: "feat: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Feature requests land faster when they describe the user problem first and stay narrow.
Rough ideas are still welcome if they are grounded in a real workflow or pain point.
- type: dropdown
id: area
attributes:
label: Affected area
options:
- CLI runtime
- Dashboard UI
- Config or auth flow
- Provider integration
- Install or packaging
- Documentation
- Contributor workflow
- type: textarea
id: problem
attributes:
label: What problem are you trying to solve?
placeholder: I manage multiple profiles, but ...
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution or direction
placeholder: Add a command or dashboard control that ...
- type: textarea
id: user-flow
attributes:
label: Suggested user flow
description: Show the command, screen, or sequence you expect.
placeholder: |
CLI:
1. `ccs ...`
Dashboard:
1. Open ...
2. Click ...
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Existing workaround, rejected approach, or why current behavior is not enough.
- type: textarea
id: context
attributes:
label: Additional context
description: Mockups, screenshots, links to related issues, or compatibility notes.
- type: checkboxes
id: checks
attributes:
label: Before submitting
options:
- label: I searched existing issues and discussions first.
- label: This request describes a concrete user problem, not just a broad idea dump.
+29
View File
@@ -0,0 +1,29 @@
## Summary
-
## Testing
Use what applies. If you skipped something, add a short note instead of forcing it.
- [ ] `bun run validate`
- [ ] `bun run validate:ci-parity`
- [ ] `cd ui && bun run validate` if UI changed
- [ ] Not run
## Checklist
Check what applies. Not every item is relevant for every PR.
- [ ] Base branch is `dev` unless this is an approved hotfix
- [ ] Branch name follows `feat/*`, `fix/*`, `docs/*`, or approved hotfix naming
- [ ] Relevant `--help` output updated if CLI behavior changed
- [ ] Tests added or updated if behavior changed
- [ ] README or local docs updated if user-facing behavior changed
- [ ] No secrets, tokens, or private config data are included
## Docs Impact
Docs impact: `none | minor | major`
Action: `no update needed` or describe what doc was updated
+2 -2
View File
@@ -17,7 +17,7 @@ Tests set `process.env.CCS_HOME` to a temp directory. Code using `os.homedir()`
## Core Function
CLI wrapper for instant switching between multiple provider accounts and alternative models (GLM, GLMT, Kimi). See README.md for user documentation.
CLI wrapper for instant switching between multiple provider accounts and alternative models (GLM, Kimi, and other API profiles). See README.md for user documentation.
## Design Principles (ENFORCE STRICTLY)
@@ -232,7 +232,7 @@ dist/ui/ → Built UI bundle (served by Express)
1. **CLIProxy hardcoded**: gemini, codex, agy → OAuth-based, zero config
2. **CLIProxy variants**: `config.cliproxy` section → user-defined providers
3. **Settings-based**: `config.profiles` section → GLM, GLMT, Kimi
3. **Settings-based**: `config.profiles` section → GLM, legacy GLMT compatibility, Kimi
4. **Account-based**: `profiles.json` → isolated instances via `CLAUDE_CONFIG_DIR`
### Settings Format (CRITICAL)
+156 -418
View File
@@ -1,463 +1,201 @@
# CCS Contributing Guide
# Contributing to CCS
Welcome! We're excited you're interested in contributing to CCS. This guide will help you get started.
CCS is a Bun + TypeScript CLI with a React dashboard. This guide is the shortest path to making a clean change without reverse-engineering the repo first.
## 🚀 Quick Start for First-Time Contributors
## Before You Start
**Never contributed before?** Start here:
- An issue is helpful for medium or large changes, but small fixes and docs updates can go straight to a PR.
- Branch from `dev`.
- Open PRs against `dev`.
- Use conventional commits.
- If you change user-facing behavior, update the docs that describe it.
1. **Find a good first issue**: Look for issues labeled [`good first issue`](https://github.com/kaitranntt/ccs/labels/good%20first%20issue)
2. **Read [CLAUDE.md](./CLAUDE.md)**: Understand the project architecture and v3.0 features
3. **Set up your environment**: See [Development Setup](#development-setup) below
4. **Make a small change**: Fix a typo, improve docs, or tackle a small bug
5. **Submit a PR**: We'll guide you through the review process
If you are new to the project, start with a docs fix, a focused bug fix, or an issue labeled `good first issue`.
**Questions?** Open a [GitHub Discussion](https://github.com/kaitranntt/ccs/discussions) - we're here to help!
## Repo Map
## Development Guidelines
| Area | Main paths | Typical follow-up |
| --- | --- | --- |
| CLI runtime | `src/`, `lib/`, `config/`, `scripts/` | Add or update tests in `tests/` |
| Dashboard UI | `ui/src/` | Run `cd ui && bun run validate` |
| Web server and config APIs | `src/web-server/`, `src/api/`, `src/config/` | Add unit or integration coverage |
| Documentation | `https://docs.ccs.kaitran.ca`, `README.md`, `docs/`, `CONTRIBUTING.md` | Keep user-facing docs in sync |
| Static assets | `assets/` | Verify screenshots and references still match |
### Philosophy
Useful directories:
CCS follows these core principles:
- `tests/unit/` for focused logic tests
- `tests/integration/` for cross-module behavior
- `tests/npm/` for packaging checks
- `tests/native/` for shell and platform coverage
- `docs/` for architecture, roadmap, and internal implementation notes
- **YAGNI**: No features "just in case"
- **KISS**: Simple bash, no complexity
- **DRY**: One source of truth (config)
## Environment Setup
This tool does ONE thing well: enable instant switching between Claude accounts and alternative models.
### Prerequisites
### Code Standards
- Node.js `>=18`
- Bun `>=1.0`
- GitHub CLI (`gh`) if you want to open PRs from the terminal
#### Compatibility Requirements
- **Unix**: bash 3.2+ compatibility
- **Windows**: PowerShell 5.1+ compatibility
- **Node.js**: Node.js 14+ (for npm package)
- **Dependencies**: Only jq (Unix) or built-in PowerShell (Windows)
#### Code Style
**Bash (Unix)**:
- Use `#!/usr/bin/env bash` shebang
- Quote variables: `"$VAR"` not `$VAR`
- Use `[[ ]]` for tests, not `[ ]`
- Follow existing indentation and naming patterns
**PowerShell (Windows)**:
- Use `CmdletBinding` and proper parameter handling
- Follow PowerShell verb-noun convention
- Use proper error handling with `try/catch`
- Maintain compatibility with PowerShell 5.1+
**Node.js (npm package)**:
- Use `child_process.spawn` for Claude CLI execution
- Handle SIGINT/SIGTERM for graceful shutdown
- Cross-platform path handling with `path` module
- ES modules preferred
### Testing
#### Platform Testing
Test on all platforms before submitting PR:
- macOS (bash)
- Linux (bash)
- Windows (PowerShell, CMD, Git Bash)
#### Test Scenarios
1. **Basic functionality**:
```bash
ccs # Should use default profile
ccs glm # Should use GLM profile
ccs kimi # Should use Kimi profile
ccs --version # Should show version
```
2. **v3.0 account-based profiles**:
```bash
ccs auth create work # Should open Claude for login
ccs work "test" # Should use work profile
# Run in different terminal concurrently:
ccs personal "test" # Should use personal profile
```
3. **With arguments**:
```bash
ccs glm --help
ccs /plan "test"
```
4. **Error handling**:
```bash
ccs invalid-profile # Should show error
ccs --invalid-flag # Should pass through to Claude
```
### Branching Strategy
#### Branch Hierarchy
```
main (production) ← dev (integration) ← feat/* | fix/* | docs/*
↑ ↑
│ └── All contributions merge here FIRST
└── Only: tested dev code OR hotfix/*
```
#### Branch Types
| Branch | Purpose | PRs Target | Releases To |
|--------|---------|------------|-------------|
| `main` | Production | From `dev` only | npm `@latest` |
| `dev` | Integration/testing | From `feat/*`, `fix/*` | npm `@dev` |
| `feat/*` | New features | → `dev` | - |
| `fix/*` | Bug fixes | → `dev` | - |
| `docs/*` | Documentation | → `dev` | - |
| `hotfix/*` | Critical fixes | → `main` directly | npm `@latest` |
#### Branch Naming Convention
```
<type>/<short-description>
# Examples:
feat/oauth-token-refresh
fix/doctor-missing-config
docs/update-installation-guide
hotfix/critical-security-fix
```
#### Development Workflow (Contributors)
### Clone and install
```bash
# 1. Fork and clone
git clone https://github.com/YOUR_USERNAME/ccs.git
cd ccs
# 2. Add upstream remote
git remote add upstream https://github.com/kaitranntt/ccs.git
# 3. ALWAYS start from latest DEV (not main!)
git checkout dev
git pull upstream dev
# 4. Create feature branch FROM DEV
git checkout -b feat/my-feature # for features
git checkout -b fix/bug-name # for bug fixes
git checkout -b docs/update-readme # for documentation
bun install
cd ui && bun install && cd ..
```
# 5. Make changes with conventional commits
git commit -m "feat(scope): add new feature"
## Branching and PRs
# 6. Push to your fork
git push -u origin feat/my-feature
Create all normal contribution branches from `dev`.
# 7. Create PR targeting DEV (not main!)
gh pr create --base dev --title "feat(scope): add new feature"
# → After merge, your changes release to npm @dev for testing
# 8. Maintainers will promote tested dev to main
# → This triggers npm @latest release
# 9. After PR merged, clean up
```bash
git checkout dev
git pull upstream dev
git branch -d feat/my-feature
git checkout -b feat/short-description
```
#### Hotfix Workflow (Critical Production Fixes Only)
Use these prefixes:
- `feat/*` for features
- `fix/*` for bug fixes
- `docs/*` for documentation-only changes
Rules:
- Never commit directly to `main` or `dev`.
- Open PRs to `dev`, not `main`.
- Treat `hotfix/*` as maintainer-only emergency flow from `main`.
- Delete your branch after merge.
Example:
```bash
# Only for critical bugs in production!
# 1. Start from main
git checkout main
git pull upstream main
# 2. Create hotfix branch
git checkout -b hotfix/critical-bug
# 3. Fix and commit
git commit -m "fix: critical security vulnerability"
# 4. PR directly to main (skip dev)
gh pr create --base main --title "fix: critical security vulnerability"
# 5. After merge, sync to dev
# (Maintainers will handle this)
git push -u origin docs/contributing-refresh
gh pr create --base dev --title "docs(contributing): refresh contributor guide"
```
#### Rules
## Local Development
- **NEVER** commit directly to `main` or `dev`
- **ALWAYS** create branches from `dev` (not main)
- **ALWAYS** target PRs to `dev` (not main)
- **ONLY** `hotfix/*` branches target `main` directly
- **DELETE** branches after merge
### Safe test environment
### Submission Process
CCS reads and writes under `~/.ccs/`. Do not test against your real setup when developing.
#### Before Submitting
Unix:
1. Ensure branch is from `dev` (not main)
2. Ensure branch follows naming: `feat/*`, `fix/*`, `docs/*`
3. Run `bun run validate` - must pass
4. Rebase on latest dev: `git rebase dev`
5. Test on all platforms if possible
#### Pull Request Requirements
- **Target `dev` branch** (not main!) - unless hotfix
- Clear description of changes
- Testing instructions if applicable
- Link to relevant issues
- **All commits MUST follow conventional format** (enforced by husky)
- **Branch MUST follow naming convention** (`feat/*`, `fix/*`, etc.)
- Run `bun run validate` before submitting
#### Commit Message Style (MANDATORY)
**All commits MUST follow conventional commit format. Non-compliant commits are automatically rejected.**
```
type(scope): description
[optional body]
[optional footer]
```
**Commit types that trigger releases:**
| Type | Version Bump |
|------|--------------|
| `feat:` | MINOR (5.0.2 → 5.1.0) |
| `fix:` | PATCH (5.0.2 → 5.0.3) |
| `perf:` | PATCH |
| `feat!:` | MAJOR (5.0.2 → 6.0.0) |
**Commit types that DON'T trigger releases:**
`docs:`, `style:`, `refactor:`, `test:`, `chore:`, `ci:`, `build:`
**Examples:**
```bash
# Good - will be accepted
git commit -m "fix(installer): handle git worktree detection"
git commit -m "feat(config): support custom config location"
git commit -m "docs(readme): update installation instructions"
git commit -m "feat!: remove deprecated API" # Breaking change
export CCS_HOME="$(mktemp -d)"
```
# Bad - will be REJECTED by husky
git commit -m "fixed bug"
PowerShell:
```powershell
$env:CCS_HOME = Join-Path $env:TEMP ("ccs-" + [guid]::NewGuid())
```
If you touch code that reads CCS paths, route it through `getCcsDir()` in `src/utils/config-manager.ts` so tests stay isolated.
### Common workflows
```bash
bun run build # Compile CLI
bun run dev # Build server and start local config dashboard
bun run dev:symlink # Point global ccs to local build
bun run dev:unlink # Restore original global ccs
cd ui && bun run dev # Dashboard-only dev server
```
Use `bun run dev` from the repo root when working on the local dashboard experience behind `ccs config`.
## Validation
If you can, run these before you open or update a PR:
```bash
bun run format
bun run lint:fix
bun run validate
bun run validate:ci-parity
```
If you changed the dashboard:
```bash
cd ui
bun run format
bun run validate
```
Helpful targeted commands:
```bash
bun run test:unit
bun run test:all
bun run test:native
bun run test:e2e
```
`bun run validate` is the main gate. It covers typechecking, linting, format checks, maintainability checks, and automated tests for the main project.
If you cannot run the full suite, that is still fine for early or docs-only PRs. Just say what you did run, or what blocked you, in the PR.
## What To Update With Your Change
### If you change CLI behavior
- Update the relevant `--help` output in `src/commands/`.
- Add or update automated coverage in `tests/`.
- Update `README.md` if the user workflow changed.
### If you change dashboard behavior
- Keep CLI and dashboard parity where the feature supports both.
- Update `ui/src/` and any affected tests.
- Run UI validation from `ui/`.
### If you change config, providers, or architecture
- Update the relevant docs in `docs/`.
- Mention migration or compatibility notes in the PR.
## Commit Style
CCS uses conventional commits because the release and workflow tooling depend on them.
```bash
git commit -m "fix(doctor): handle missing config gracefully"
git commit -m "feat(cliproxy): add provider quota check"
git commit -m "docs(contributing): simplify contributor workflow"
```
Avoid:
```bash
git commit -m "fix stuff"
git commit -m "WIP"
git commit -m "updated stuff"
git commit -m "update file"
```
### Development Setup
## Release Notes
#### Local Development
Releases are automated with semantic-release.
```bash
# Clone your fork
git clone https://github.com/yourusername/ccs.git
cd ccs
- Merges to `dev` publish the `@dev` channel.
- Merges to `main` publish the `@latest` channel.
- Do not manually bump versions, create tags, or run manual `npm publish`.
# Create feature branch
git checkout -b your-feature-name
## Need Help?
# Option 1: Test with built binary
# Test locally with ./dist/ccs.js
# Option 2: Symlink for seamless testing (recommended)
bun run build
bun run dev:symlink # Symlinks global 'ccs' to dev version
# Now 'ccs' command uses your dev changes!
# Make changes
# Test with: ccs <command>
# When done developing:
bun run dev:unlink # Restores original global ccs
# Run tests
# Test with: ccs <command>
# Run tests
bun run test # All tests
bun run test:native # Native Unix tests only
```
#### Testing npm Package Locally
```bash
# Build and test npm package
npm pack # Creates @kaitranntt-ccs-X.Y.Z.tgz
npm install -g @kaitranntt-ccs-X.Y.Z.tgz # Test installation
ccs --version # Verify it works
ccs glm "test" # Test functionality
# Cleanup
npm uninstall -g @kaitranntt/ccs
rm @kaitranntt-ccs-X.Y.Z.tgz
rm -rf ~/.ccs # Clean test environment
```
#### Testing Installer
```bash
# Test Unix installer
./installers/install.sh
# Test Windows installer (in PowerShell)
.\installers\install.ps1
```
### Areas for Contribution
**Looking for where to start?** Check [GitHub Issues](https://github.com/kaitranntt/ccs/issues) for:
- [`good first issue`](https://github.com/kaitranntt/ccs/labels/good%20first%20issue) - Great for first-time contributors
- [`help wanted`](https://github.com/kaitranntt/ccs/labels/help%20wanted) - We need your expertise!
- [`documentation`](https://github.com/kaitranntt/ccs/labels/documentation) - Improve our docs
#### Priority Areas
1. **v3.0 Enhancements**:
- Profile management commands
- Better instance isolation
- Profile import/export
2. **Enhanced error handling**:
- Better error messages
- Recovery suggestions
- Helpful Claude CLI detection
3. **Documentation**:
- More usage examples
- Integration guides
- Video tutorials
4. **Testing**:
- Expand test coverage
- Add CI/CD tests
- Performance benchmarks
#### Bug Fixes
- Installer issues on different platforms
- Edge cases in config parsing
- Windows-specific compatibility
- v3.0 concurrent session edge cases
### Review Process
**What to expect:**
1. **Automated checks** (GitHub Actions):
- Syntax validation
- Basic functionality tests
- npm package build test
2. **Manual review** (usually within 1-3 days):
- Code quality and style
- Platform compatibility
- Philosophy alignment (YAGNI/KISS/DRY)
3. **Testing** (by maintainers):
- Cross-platform verification (macOS, Linux, Windows)
- Integration testing
- v3.0 features validation
**Tips for faster review:**
- Keep PRs focused and small
- Include tests for new features
- Test on multiple platforms before submitting
- Link to related issues
### Community
#### Getting Help
- **GitHub Issues**: Report bugs or request features
- **Discussions**: Ask questions or share ideas
- **README**: Check [README.md](./README.md) for usage examples
#### Communication Channels
- Primary: [GitHub Issues](https://github.com/kaitranntt/ccs/issues)
- Questions: [GitHub Discussions](https://github.com/kaitranntt/ccs/discussions)
- Updates: Watch the repository for release notifications
#### Code of Conduct
Be respectful, constructive, and focused on the project's philosophy of simplicity and reliability.
**We do not tolerate:**
- Harassment or discrimination
- Spam or off-topic comments
- Disrespectful or unprofessional behavior
**We encourage:**
- Helpful feedback and constructive criticism
- Collaboration and knowledge sharing
- Patience with newcomers
## 📚 Additional Resources
- **[CLAUDE.md](./CLAUDE.md)**: Technical architecture and v3.0 implementation details
- **[README.md](./README.md)**: User-facing documentation and examples
- **[GitHub Issues](https://github.com/kaitranntt/ccs/issues)**: Track bugs, features, and discussions
- **[VERSION](./VERSION)**: Current version number
## 🎯 Release Process (FULLY AUTOMATED)
**Releases are automated via semantic-release. DO NOT manually bump versions or create tags.**
### How Releases Work
1. **Write conventional commits** during development
2. **Merge PR to `main`** (or push to `dev`)
3. **CI automatically:**
- Analyzes commits since last release
- Determines version bump from commit types
- Updates CHANGELOG.md, VERSION, package.json
- Creates git tag
- Publishes to npm
- Creates GitHub release
### Release Channels
| Branch | npm Tag | Use Case |
|--------|---------|----------|
| `main` | `@latest` | Stable production releases |
| `dev` | `@dev` | Pre-release testing |
### Workflow
```bash
# Stable release
git checkout -b feat/my-feature
git commit -m "feat: add new feature"
gh pr create --base main
# → Merge PR → CI auto-releases to npm @latest
# Dev release
git checkout dev
git merge feat/experimental
git push origin dev
# → CI auto-releases to npm @dev
```
**NEVER DO:**
- `./scripts/bump-version.sh` (deprecated, emergency only)
- `git tag vX.Y.Z` (tags are auto-created)
- Manual `npm publish` (CI handles it)
## 📄 License
By contributing to CCS, you agree that your contributions will be licensed under the MIT License.
---
**Thank you for contributing to CCS!**
Remember: Keep it simple, test thoroughly, and stay true to the YAGNI/KISS/DRY philosophy.
- Bugs and features: https://github.com/kaitranntt/ccs/issues
- Questions and discussion: https://github.com/kaitranntt/ccs/discussions
- Hosted docs: https://docs.ccs.kaitran.ca
- User-facing docs: [README.md](./README.md)
- Internal architecture notes: [docs/](./docs)
- Community expectations: [`.github/CODE_OF_CONDUCT.md`](./.github/CODE_OF_CONDUCT.md)
+26 -4
View File
@@ -11,7 +11,7 @@ Run Claude, Gemini, GLM, and any Anthropic-compatible API - concurrently, withou
[![npm](https://img.shields.io/npm/v/@kaitranntt/ccs?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@kaitranntt/ccs)
[![PoweredBy](https://img.shields.io/badge/PoweredBy-ClaudeKit-C15F3C?style=for-the-badge)](https://claudekit.cc?ref=HMNKXOHN)
**[Features & Pricing](https://ccs.kaitran.ca)** | **[Documentation](https://docs.ccs.kaitran.ca)**
**[Features & Pricing](https://ccs.kaitran.ca)** | **[Documentation Hub](https://docs.ccs.kaitran.ca)**
</div>
@@ -29,6 +29,9 @@ Run Claude, Gemini, GLM, and any Anthropic-compatible API - concurrently, withou
## Quick Start
Looking for the full setup guide, command reference, provider guides, or troubleshooting?
Start at **https://docs.ccs.kaitran.ca**.
### 1. Install
```bash
@@ -50,9 +53,23 @@ bun add -g @kaitranntt/ccs # bun (30x faster)
```bash
ccs config
# Opens http://localhost:3000
# Opens a local browser URL
```
CCS uses the runtime's system-default bind. If that bind is reachable beyond loopback,
the CLI also prints bind/network details plus an auth reminder.
Force all-interface binding for remote devices:
```bash
ccs config --host 0.0.0.0
# Terminal prints the reachable URLs to open from the other device
```
If you expose the dashboard beyond localhost, protect it first with `ccs config auth setup`.
Use `ccs config --host 127.0.0.1` to force local-only binding.
Dashboard updates hub: `http://localhost:3000/updates`
Want to run the dashboard in Docker or pull the prebuilt image? See `docker/README.md`.
@@ -110,6 +127,7 @@ The dashboard provides visual management for all account types:
| **Azure Foundry** | API Key | `ccs foundry` | Claude via Microsoft Azure |
| **Minimax** | API Key | `ccs mm` | M2 series, 1M context |
| **DeepSeek** | API Key | `ccs deepseek` | V3.2 and R1 reasoning |
| **Novita AI** | API Key | `ccs api create --preset novita` | Anthropic-compatible Novita endpoint for Claude Code |
| **Qwen (OAuth)** | OAuth | `ccs qwen` | Qwen Code via CLIProxy |
| **Qwen API** | API Key | `ccs api create --preset qwen` | DashScope Anthropic-compatible API |
| **Alibaba Coding Plan** | API Key | `ccs api create --preset alibaba-coding-plan` | Model Studio Coding Plan endpoint |
@@ -593,10 +611,14 @@ Notes:
<br>
## Documentation
## Documentation Hub
If you are not sure where to start, open **https://docs.ccs.kaitran.ca** first.
The hosted docs are the best entry point for setup, command reference, provider guides, and troubleshooting.
| Topic | Link |
|-------|------|
| Docs Home | [docs.ccs.kaitran.ca](https://docs.ccs.kaitran.ca) |
| Installation | [docs.ccs.kaitran.ca/getting-started/installation](https://docs.ccs.kaitran.ca/getting-started/installation) |
| Configuration | [docs.ccs.kaitran.ca/getting-started/configuration](https://docs.ccs.kaitran.ca/getting-started/configuration) |
| OAuth Providers | [docs.ccs.kaitran.ca/providers/oauth-providers](https://docs.ccs.kaitran.ca/providers/oauth-providers) |
@@ -658,6 +680,6 @@ MIT License - see [LICENSE](LICENSE).
---
**[ccs.kaitran.ca](https://ccs.kaitran.ca)** | [Report Issues](https://github.com/kaitranntt/ccs/issues) | [Star on GitHub](https://github.com/kaitranntt/ccs)
**[ccs.kaitran.ca](https://ccs.kaitran.ca)** | **[docs.ccs.kaitran.ca](https://docs.ccs.kaitran.ca)** | [Report Issues](https://github.com/kaitranntt/ccs/issues) | [Star on GitHub](https://github.com/kaitranntt/ccs)
</div>
+4 -4
View File
@@ -2,9 +2,9 @@
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codex",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "gpt-5.3-codex",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5.3-codex",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.3-codex",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.1-codex-mini"
"ANTHROPIC_MODEL": "gpt-5-codex",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5-codex",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5-codex",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5-codex-mini"
}
}
+15 -7
View File
@@ -1,6 +1,6 @@
# CCS Codebase Summary
Last Updated: 2026-02-24
Last Updated: 2026-03-17
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, and account-context validation hardening.
@@ -34,23 +34,29 @@ The main CLI is organized into domain-specific modules with barrel exports.
```
src/
├── ccs.ts # Main entry point & CLI router
├── ccs.ts # Main entry point & profile execution flow
├── types/ # TypeScript type definitions
│ ├── index.ts # Barrel export (aggregates all types)
│ ├── cli.ts # CLI types (ParsedArgs, ExitCode)
│ ├── config.ts # Config types (Settings, EnvVars)
│ ├── delegation.ts # Delegation types (sessions, events)
│ ├── glmt.ts # GLMT types (messages, transforms)
│ ├── glmt.ts # Legacy transformer types (messages, transforms)
│ └── utils.ts # Utility types (ErrorCode, LogLevel)
├── commands/ # CLI command handlers
│ ├── api-command/ # API profile subcommands (split facade + handlers)
│ │ ├── index.ts # API command facade/router
│ │ ├── shared.ts # Shared API arg parsing helpers
│ │ └── [subcommand files...]
│ ├── cliproxy-command.ts # CLIProxy subcommand handling
│ ├── config-command.ts # Config management commands
│ ├── config-image-analysis-command.ts # Image analysis hook config (NEW v7.34)
│ ├── named-command-router.ts # Reusable named-command dispatcher
│ ├── doctor-command.ts # Health diagnostics
│ ├── env-command.ts # Export shell env vars for third-party tools (v7.39)
│ ├── help-command.ts # Help text generation
│ ├── install-command.ts # Install/uninstall logic
│ ├── root-command-router.ts # Extracted top-level command dispatch from ccs.ts
│ ├── shell-completion-command.ts
│ ├── sync-command.ts # Symlink synchronization
│ ├── update-command.ts # Self-update logic
@@ -96,6 +102,7 @@ src/
│ ├── auth-handler.ts # Authentication handling
│ ├── model-catalog.ts # Provider model definitions
│ ├── model-config.ts # Model configuration
│ ├── codex-plan-compatibility.ts # Codex free/paid model fallback guardrails
│ ├── service-manager.ts # Background service
│ ├── proxy-detector.ts # Running proxy detection
│ ├── startup-lock.ts # Race condition prevention
@@ -108,11 +115,11 @@ src/
│ ├── index.ts # Barrel export
│ └── copilot-package-manager.ts # Package management (515 lines)
├── glmt/ # GLM/GLMT integration
├── glmt/ # Legacy transformer internals kept for compatibility
│ ├── index.ts # Barrel export
│ ├── pipeline/ # Processing pipeline
│ │ └── index.ts
│ ├── glmt-proxy.ts # Main proxy (675 lines)
│ ├── glmt-proxy.ts # Legacy proxy runtime kept for internal compatibility
│ └── delta-accumulator.ts # Delta processing (484 lines)
├── delegation/ # Task delegation & headless execution
@@ -194,7 +201,7 @@ src/
| Targets | `targets/` | Multi-CLI adapter pattern (Claude Code, Factory Droid, extensible) |
| Auth | `auth/`, `cliproxy/auth/` | Authentication across providers |
| Config | `config/`, `types/` | Configuration & type definitions |
| Providers | `cliproxy/`, `copilot/`, `glmt/` | Provider integrations (7 CLIProxy providers: gemini, codex, agy, qwen, iflow, kiro, ghcp) |
| Providers | `cliproxy/`, `copilot/`, `glmt/` | Provider integrations plus retained legacy transformer internals |
| Quota | `cliproxy/quota-*.ts`, `account-manager.ts` | Hybrid quota management (v7.14) |
| Remote Proxy | `cliproxy/remote-*.ts`, `proxy-config-resolver.ts` | Remote CLIProxy support (v7.1) |
| Image Analysis | `utils/image-analysis/`, `utils/hooks/` | Vision model proxying (v7.34) |
@@ -364,6 +371,7 @@ ui/src/
│ ├── button.tsx
│ ├── card.tsx
│ ├── dialog.tsx
│ ├── searchable-select.tsx # Shared searchable combobox for model pickers
│ ├── sidebar.tsx # Custom sidebar (674 lines)
│ └── [UI primitives...]
@@ -469,7 +477,7 @@ ui/src/
| File | Lines | Status |
|------|-------|--------|
| model-pricing.ts | 676 | Data file - acceptable |
| glmt-proxy.ts | 675 | Complex streaming - acceptable |
| glmt-proxy.ts | 675 | Legacy internal compatibility path - acceptable for now |
| cliproxy-executor.ts | 666 | Core logic - acceptable |
| cliproxy-command.ts | 634 | Could split if needed |
| usage/handlers.ts | 633 | Could split if needed |
+3 -1
View File
@@ -5,6 +5,7 @@ This guide covers the local Cursor integration in CCS, including CLI setup, daem
## What It Provides
- OpenAI-compatible local endpoint powered by Cursor credentials.
- Anthropic-compatible local endpoint at `/v1/messages` for Claude-native clients.
- Cursor model list and chat completions via local daemon.
- Dedicated dashboard page: `ccs config` -> `Cursor IDE`.
@@ -61,6 +62,7 @@ ccs cursor stop
- `auto_start`: disabled
- Model list resolution: authenticated live fetch when available, with cached/default fallback.
- Request model validation: if a requested model is not present in the available Cursor model catalog, daemon falls back to the resolved default model.
- Daemon API surface: `POST /v1/chat/completions`, `POST /v1/messages`, and `GET /v1/models`.
These values are managed in unified config and can be updated from CLI or dashboard.
@@ -80,7 +82,7 @@ Available controls:
- Auth actions (auto-detect, manual import)
- Daemon actions (start/stop)
- Runtime config (port, auto-start, ghost mode)
- Models list
- Models list with searchable combobox filtering for large catalogs
- Raw editor for `~/.ccs/cursor.settings.json`
## Raw Settings and Unified Config Sync
+2 -2
View File
@@ -1,12 +1,12 @@
# Dashboard Authentication CLI
Last Updated: 2026-02-26
Last Updated: 2026-03-17
CLI commands for managing CCS dashboard authentication.
## Overview
The CCS dashboard (`ccs config`) can be protected with username/password authentication. This is useful when running the dashboard on a network-accessible machine.
The CCS dashboard (`ccs config`) can be protected with username/password authentication. This is useful whenever the dashboard is reachable from another device, including when the runtime's default bind is network-accessible or when you explicitly bind it beyond loopback with `ccs config --host 0.0.0.0`.
Authentication is **disabled by default** for backward compatibility. Use the CLI to configure and enable it.
+11 -2
View File
@@ -1,6 +1,6 @@
# CCS Project Roadmap
Last Updated: 2026-02-12
Last Updated: 2026-03-17
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
@@ -39,6 +39,15 @@ All major modularization work is complete. The codebase evolved from monolithic
## Current Status
### Recent Fixes
- **2026-03-17**: Deprecated user-facing GLMT discovery across CLI help, completions, presets, and docs. Existing `glmt` profiles now run through a compatibility path that normalizes legacy proxy settings to the direct GLM endpoint.
- **#748**: API profile creation now keeps provider selection compact by collapsing advanced presets behind an explicit toggle, shrinking chooser cards so the form fields stay visually primary, and giving `llama.cpp` a dedicated provider logo.
- **#744**: API profile creation now keeps featured providers in a horizontal rail with scroll fallback, moves Anthropic Direct API to the end, reuses the shared Claude logo, and separates the custom-endpoint entry point from advanced template discovery.
- **#724**: Codex startup is now free-plan safe. CCS defaults new Codex sessions to a cross-plan model and auto-repairs stale paid-only Codex defaults when the active account is on the free plan.
- **#737**: Dashboard model pickers in Cursor, Copilot, and CLIProxy now use a searchable combobox with autofocus and explicit no-results states for large model catalogs.
- **#736**: `ccs config` now supports explicit dashboard bind hosts via `--host`, and surfaces remote-access warnings plus reachable URLs when the effective bind is non-loopback.
### Maintainability Hardening Kickoff
- Issue owner: Stream D for **#542**
@@ -52,7 +61,7 @@ All major modularization work is complete. The codebase evolved from monolithic
**CLI** (complex core logic):
- `model-pricing.ts` (676 lines) - Data file
- `glmt-proxy.ts` (675 lines) - Streaming proxy
- `glmt-proxy.ts` (675 lines) - Legacy internal compatibility proxy
- `cliproxy-executor.ts` (666 lines) - Core execution
- `ccs.ts` (596 lines) - Entry point
+11 -8
View File
@@ -55,7 +55,7 @@ CCS v7.45 introduces the Target Adapter pattern, enabling seamless integration w
**Key architecture:**
```
Profile Resolution (CLIProxy, GLMT, Account-based)
Profile Resolution (CLIProxy, Settings/API, Account-based)
|
v
Target Resolution (--target flag > config > argv[0] > default)
@@ -126,7 +126,7 @@ For details on the adapter architecture, see [Target Adapters](./target-adapters
| |
+---> [CLIProxy Provider] ---> execClaudeWithCLIProxy()
| |
+---> [GLMT Profile] ---> execClaudeWithProxy()
+---> [Settings/API Profile] ---> normalize legacy glmt if needed
|
v
+------------------+
@@ -185,20 +185,23 @@ For details on the adapter architecture, see [Target Adapters](./target-adapters
| v
| 7b. Spawn via Adapter
|
+---> GLMT -----------> 3c. Start Embedded Proxy
+---> Settings/API ---> 3c. Load settings env
|
v
4c. Resolve Target Adapter
4c. Normalize legacy glmt if needed
|
v
5c. Spawn via Adapter
5c. Resolve Target Adapter
|
v
6c. Spawn via Adapter
```
---
## Provider Integration Architecture
For detailed provider flows (CLIProxyAPI, GLMT, quota management), see [Provider Flows](./provider-flows.md).
For detailed provider flows (CLIProxyAPI, legacy GLMT compatibility, quota management), see [Provider Flows](./provider-flows.md).
---
@@ -303,7 +306,7 @@ See [Provider Flows](./provider-flows.md) → Authentication Flow section.
| Localhost only (127.0.0.1)
v
+------------------+
| CLIProxy/GLMT | Binds to localhost only
| CLIProxy/Legacy | Binds to localhost only
+------------------+
|
| TLS encrypted
@@ -400,5 +403,5 @@ See [Provider Flows](./provider-flows.md) → Authentication Flow section.
- [Codebase Summary](../codebase-summary.md) - Detailed directory structure
- [Code Standards](../code-standards.md) - Coding conventions & patterns
- [Target Adapters](./target-adapters.md) - Multi-CLI adapter architecture
- [Provider Flows](./provider-flows.md) - CLIProxy, GLMT, authentication flows
- [Provider Flows](./provider-flows.md) - CLIProxy, legacy GLMT compatibility, authentication flows
- [Project Roadmap](../project-roadmap.md) - Development phases
+19 -27
View File
@@ -2,7 +2,7 @@
Last Updated: 2026-02-16
Detailed provider integration flows including CLIProxyAPI, GLMT proxy, remote CLIProxy, quota management, and authentication.
Detailed provider integration flows including CLIProxyAPI, legacy GLMT compatibility transforms, remote CLIProxy, quota management, and authentication.
---
@@ -91,63 +91,55 @@ if (hardcodedProviders.includes(profileName)) {
---
## GLMT Proxy Flow
## Legacy GLMT Compatibility Flow
### Overview
GLMT proxy enables seamless integration with GLM-compatible APIs (Z.AI, Kimi, OpenRouter, etc.) using a Node.js-based embedded proxy.
GLMT is no longer a marketed runtime surface in CCS. Existing `glmt` profiles are kept as a compatibility path and normalized at launch to the direct GLM endpoint. The `src/glmt/` module remains because Cursor response translation still imports its transformer pipeline.
```
+===========================================================================+
| GLMT Proxy Integration |
| Legacy GLMT Compatibility + Internal Transforms |
+===========================================================================+
Claude CLI
|
| ANTHROPIC_BASE_URL = localhost:XXXX
| legacy glmt settings detected
v
+------------------+
| GLMT Proxy | Embedded Node.js proxy (src/glmt/)
| (glmt-proxy.ts)|
| Compatibility | normalizeDeprecatedGlmtEnv()
| Layer | (src/utils/glmt-deprecation.ts)
+------------------+
|
v
+------------------+
| Delta Accumulator| Stream transformation
| Direct GLM API | https://api.z.ai/api/anthropic
+------------------+
|
v
+------------------+
| Pipeline | Request/Response transformation
+------------------+
|
v
+------------------+
| GLM API | Z.AI / Kimi API
| src/glmt/* | retained for Cursor translation
+------------------+
```
### Supported GLM Providers
### Supported Migration Targets
| Provider | Config Key | Endpoint | Auth |
|----------|------------|----------|------|
| Z.AI (GLM) | `glmt` | https://open.bigmodel.cn/api/paas/v4/ | API key |
| Kimi | `kimi` | https://api.moonshot.cn/v1/ | API key |
| OpenRouter | `openrouter` | https://openrouter.ai/api/v1/ | API key |
| Z.AI (GLM) | `glm` | https://api.z.ai/api/anthropic | API key |
| Kimi API | `km` | https://api.kimi.com/coding/ | API key |
| Legacy compatibility | `glmt` | normalized to direct GLM at runtime | existing profile only |
Note for `config/base-kimi.settings.json`: the default base URL is `http://127.0.0.1:8317/api/provider/kimi` (local CLIProxy route). For direct Moonshot API access, override `ANTHROPIC_BASE_URL` to `https://api.moonshot.cn/v1/`.
Use `ccs glm` for Z.AI profiles and `ccs km` for reasoning-first Kimi API profiles. Keep `glmt` only when migrating an existing settings file.
### GLMT Profile Detection
### Runtime Handling
CCS detects GLMT profiles and routes through `execClaudeWithProxy()`:
CCS detects the deprecated `glmt` profile name and normalizes legacy proxy-only settings before dispatching through the normal settings-profile flow:
```typescript
// Settings-based profile detection
const settings = loadSettings(profileName);
if (settings.env?.ANTHROPIC_BASE_URL?.includes('glm') ||
settings.env?.ANTHROPIC_BASE_URL?.includes('moonshot') ||
settings.env?.ANTHROPIC_BASE_URL?.includes('openrouter')) {
return execClaudeWithProxy(claudeCli, profileName, args);
if (isDeprecatedGlmtProfileName(profileName)) {
const normalized = normalizeDeprecatedGlmtEnv(settingsEnv);
// warn user, validate against direct GLM endpoint, continue through settings flow
}
```
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@kaitranntt/ccs",
"version": "7.54.0",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"version": "7.54.0-dev.11",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",
"claude",
@@ -58,6 +58,7 @@
"build:server": "tsc && node scripts/add-shebang.js",
"build:all": "bun run ui:build && bun run build:server",
"prebuild": "node scripts/clean-dist.js",
"prebuild:server": "node scripts/clean-dist.js",
"prebuild:all": "rm -rf dist tsconfig.tsbuildinfo",
"postbuild:all": "node scripts/verify-bundle.js",
"typecheck": "tsc --noEmit",
+1 -1
View File
@@ -116,7 +116,7 @@ sudo cp scripts/completion/ccs.fish /usr/share/fish/vendor_completions.d/
```bash
$ ccs <TAB>
auth doctor glm glmt kimi work personal --help --version
auth doctor glm kimi work personal --help --version
$ ccs auth <TAB>
create list show remove default --help
+1 -2
View File
@@ -26,7 +26,7 @@ end
# Helper function to get custom/unknown settings profiles
function __fish_ccs_get_custom_settings_profiles
set -l config_path ~/.ccs/config.json
set -l known_profiles default glm glmt kimi
set -l known_profiles default glm kimi
if test -f $config_path
set -l all_profiles (jq -r '.profiles | keys[]' $config_path 2>/dev/null)
@@ -138,7 +138,6 @@ complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env
# Model profiles - grouped with [model] prefix for visual distinction
complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'default' -d '[model] Default Claude Sonnet 4.5'
complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'glm' -d '[model] GLM-4.6 (cost-optimized)'
complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'glmt' -d '[model] GLM-4.6 with thinking mode'
complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'kimi' -d '[model] Kimi for Coding (long-context)'
# Custom model profiles - dynamic with [model] prefix
+1 -2
View File
@@ -15,7 +15,7 @@
# Set up completion styles for better formatting and colors
zstyle ':completion:*:*:ccs:*:commands' list-colors '=(#b)(auth|api|cliproxy|doctor|env|sync|update)([[:space:]]#--[[:space:]]#*)==0\;34=2\;37'
zstyle ':completion:*:*:ccs:*:proxy-profiles' list-colors '=(#b)(gemini|codex|agy|qwen)([[:space:]]#--[[:space:]]#*)==0\;35=2\;37'
zstyle ':completion:*:*:ccs:*:model-profiles' list-colors '=(#b)(default|glm|glmt|kimi|[^[:space:]]##)([[:space:]]#--[[:space:]]#*)==0\;32=2\;37'
zstyle ':completion:*:*:ccs:*:model-profiles' list-colors '=(#b)(default|glm|kimi|[^[:space:]]##)([[:space:]]#--[[:space:]]#*)==0\;32=2\;37'
zstyle ':completion:*:*:ccs:*:account-profiles' list-colors '=(#b)([^[:space:]]##)([[:space:]]#--[[:space:]]#*)==0\;33=2\;37'
zstyle ':completion:*:*:ccs:*' group-name ''
zstyle ':completion:*:*:ccs:*:descriptions' format $'\n%B%F{yellow}── %d ──%f%b'
@@ -56,7 +56,6 @@ _ccs() {
profile_descriptions=(
'default' 'Default Claude Sonnet 4.5'
'glm' 'GLM-4.6 (cost-optimized)'
'glmt' 'GLM-4.6 with thinking mode'
'kimi' 'Kimi for Coding (long-context)'
)
+3 -3
View File
@@ -150,7 +150,7 @@ function createConfigFiles() {
// Create config.yaml if missing (primary format)
// NOTE: gemini/codex profiles NOT included - they are added on-demand when user
// runs `ccs gemini` or `ccs codex` for first time (requires OAuth auth first)
// NOTE: GLM/GLMT/Kimi profiles are now created via UI/CLI presets, not auto-created
// NOTE: GLM/Kimi profiles are now created via UI/CLI presets, not auto-created
const configYamlPath = path.join(ccsDir, 'config.yaml');
const legacyConfigPath = path.join(ccsDir, 'config.json');
@@ -250,10 +250,10 @@ function createConfigFiles() {
console.log(' config.json will be ignored - consider removing it');
}
// NOTE: GLM, GLMT, and Kimi profiles are NO LONGER auto-created during install
// NOTE: GLM and Kimi profiles are NO LONGER auto-created during install
// Users can create these via:
// - UI: Profile Create Dialog → Provider Presets
// - CLI: ccs api create --preset glm|glmt|kimi
// - CLI: ccs api create --preset glm|km
// This gives users control over which providers they want to use
// Existing profiles are preserved for backward compatibility
+77 -431
View File
@@ -1,13 +1,10 @@
import './utils/fetch-proxy-setup';
import { spawn, ChildProcess } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
import { detectClaudeCli } from './utils/claude-detector';
import {
getSettingsPath,
loadSettings,
getCcsDir,
setGlobalConfigDir,
detectCloudSyncPath,
} from './utils/config-manager';
@@ -40,24 +37,11 @@ import { isCopilotSubcommandToken } from './copilot/constants';
// Import centralized error handling
import { handleError, runCleanup } from './errors';
// Import extracted command handlers
import { handleVersionCommand } from './commands/version-command';
import { handleHelpCommand } from './commands/help-command';
import { handleInstallCommand, handleUninstallCommand } from './commands/install-command';
import { handleDoctorCommand } from './commands/doctor-command';
import { handleSyncCommand } from './commands/sync-command';
import { handleShellCompletionCommand } from './commands/shell-completion-command';
import { handleUpdateCommand } from './commands/update-command';
import { tryHandleRootCommand } from './commands/root-command-router';
// Import extracted utility functions
import {
execClaude,
escapeShellArg,
stripClaudeCodeEnv,
getClaudeLaunchEnvOverrides,
} from './utils/shell-executor';
import { wireChildProcessSignals } from './utils/signal-forwarder';
import { execClaude } from './utils/shell-executor';
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
// Import target adapter system
import {
@@ -106,198 +90,6 @@ function detectProfile(args: string[]): DetectedProfile {
}
}
// ========== GLMT Proxy Execution ==========
/**
* Execute Claude CLI with embedded proxy (for GLMT profile)
*/
async function execClaudeWithProxy(
claudeCli: string,
profileName: string,
args: string[],
claudeConfigDir?: string
): Promise<void> {
// 1. Read settings to get API key
const settingsPath = getSettingsPath(profileName);
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const envData = settings.env;
const apiKey = envData['ANTHROPIC_AUTH_TOKEN'];
if (!apiKey || apiKey === 'YOUR_GLM_API_KEY_HERE') {
console.error(fail('GLMT profile requires Z.AI API key'));
console.error(` Edit ${getCcsDir()}/glmt.settings.json and set ANTHROPIC_AUTH_TOKEN`);
process.exit(1);
}
// Detect verbose flag
const verbose = args.includes('--verbose') || args.includes('-v');
// 2. Spawn embedded proxy with verbose flag
const proxyPath = path.join(__dirname, 'glmt', 'glmt-proxy.js');
const proxyArgs = verbose ? ['--verbose'] : [];
// Use process.execPath for Windows compatibility (CVE-2024-27980)
// Pass environment variables to proxy subprocess (required for auth)
const proxy = spawn(process.execPath, [proxyPath, ...proxyArgs], {
stdio: ['ignore', 'pipe', verbose ? 'pipe' : 'inherit'],
env: {
...process.env,
ANTHROPIC_AUTH_TOKEN: apiKey,
ANTHROPIC_BASE_URL: envData['ANTHROPIC_BASE_URL'],
},
});
const stopProxy = (): void => {
try {
if (!proxy.killed) {
proxy.kill('SIGTERM');
}
} catch {
// Best-effort cleanup on process teardown.
}
};
// 3. Wait for proxy ready signal (with timeout)
const { ProgressIndicator } = await import('./utils/progress-indicator');
const spinner = new ProgressIndicator('Starting GLMT proxy');
spinner.start();
let port: number;
try {
port = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Proxy startup timeout (5s)'));
}, 5000);
proxy.stdout?.on('data', (data: Buffer) => {
const match = data.toString().match(/PROXY_READY:(\d+)/);
if (match) {
clearTimeout(timeout);
resolve(parseInt(match[1]));
}
});
proxy.on('error', (error) => {
clearTimeout(timeout);
reject(error);
});
proxy.on('exit', (code) => {
if (code !== 0 && code !== null) {
clearTimeout(timeout);
reject(new Error(`Proxy exited with code ${code}`));
}
});
});
spinner.succeed(`GLMT proxy ready on port ${port}`);
} catch (error) {
const err = error as Error;
spinner.fail('Failed to start GLMT proxy');
console.error(fail(`Error: ${err.message}`));
console.error('');
console.error('Possible causes:');
console.error(' 1. Port conflict (unlikely with random port)');
console.error(' 2. Node.js permission issue');
console.error(' 3. Firewall blocking localhost');
console.error('');
console.error('Workarounds:');
console.error(' - Use non-thinking mode: ccs glm "prompt"');
console.error(' - Enable verbose logging: ccs glmt --verbose "prompt"');
console.error(` - Check proxy logs in ${getCcsDir()}/logs/ (if debug enabled)`);
console.error('');
stopProxy();
runCleanup();
process.exit(1);
}
// 4. Spawn Claude CLI with proxy URL
// Use model from user's settings (not hardcoded) - fixes issue #358
const configuredModel = envData['ANTHROPIC_MODEL'] || 'glm-5';
const envVars: NodeJS.ProcessEnv = {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
ANTHROPIC_AUTH_TOKEN: apiKey,
ANTHROPIC_MODEL: configuredModel,
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
};
const isWindows = process.platform === 'win32';
const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli);
const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli);
const webSearchEnv = getWebSearchHookEnv();
const imageAnalysisEnv = getImageAnalysisHookEnv(profileName);
const claudeLaunchEnv = getClaudeLaunchEnvOverrides();
const env = stripClaudeCodeEnv({
...process.env,
...claudeLaunchEnv,
...envVars,
...webSearchEnv,
...imageAnalysisEnv,
CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider
});
let claude: ChildProcess;
if (isPowerShellScript) {
claude = spawn(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args],
{
stdio: 'inherit',
windowsHide: true,
env,
}
);
} else if (needsShell) {
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
claude = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: true,
env,
});
} else {
claude = spawn(claudeCli, args, {
stdio: 'inherit',
windowsHide: true,
env,
});
}
// 5. Shared signal forwarding + proxy cleanup lifecycle
wireChildProcessSignals(
claude,
(err: NodeJS.ErrnoException) => {
if (err.code === 'EACCES') {
console.error(fail(`Claude CLI is not executable: ${claudeCli}`));
console.error(' Check file permissions and executable bit.');
} else if (err.code === 'ENOENT') {
if (isPowerShellScript) {
console.error(
fail('PowerShell executable not found (required for .ps1 wrapper launch).')
);
console.error(' Ensure powershell.exe is available in PATH.');
} else if (needsShell) {
console.error(fail('Windows command shell not found for Claude wrapper launch.'));
console.error(' Ensure cmd.exe is available and accessible.');
} else {
console.error(fail(`Claude CLI not found: ${claudeCli}`));
}
} else {
console.error(fail(`Claude CLI error: ${err.message}`));
}
stopProxy();
runCleanup();
process.exit(1);
},
(code: number | null, signal: NodeJS.Signals | null) => {
stopProxy();
if (signal) {
process.kill(process.pid, signal);
} else {
process.exit(code || 0);
}
}
);
}
// ========== Main Execution ==========
interface ProfileError extends Error {
@@ -462,119 +254,7 @@ async function main(): Promise<void> {
console.warn('[!] Recovery failed:', (err as Error).message);
}
// Special case: migrate command
if (firstArg === 'migrate' || firstArg === '--migrate') {
const { handleMigrateCommand, printMigrateHelp } = await import('./commands/migrate-command');
const migrateArgs = args.slice(1);
if (migrateArgs.includes('--help') || migrateArgs.includes('-h')) {
printMigrateHelp();
return;
}
await handleMigrateCommand(migrateArgs);
return;
}
// Special case: update command
if (firstArg === 'update' || firstArg === '--update') {
const updateArgs = args.slice(1);
// Handle --help for update command
if (updateArgs.includes('--help') || updateArgs.includes('-h')) {
console.log('');
console.log('Usage: ccs update [options]');
console.log('');
console.log('Options:');
console.log(' --force Force reinstall current version');
console.log(' --beta, --dev Install from dev channel (unstable)');
console.log(' --help, -h Show this help message');
console.log('');
console.log('Examples:');
console.log(' ccs update Update to latest stable');
console.log(' ccs update --force Force reinstall');
console.log(' ccs update --beta Install dev channel');
console.log('');
return;
}
const forceFlag = updateArgs.includes('--force');
const betaFlag = updateArgs.includes('--beta') || updateArgs.includes('--dev');
await handleUpdateCommand({ force: forceFlag, beta: betaFlag });
return;
}
const commandAliases: Record<string, string> = {
'--version': 'version',
'-v': 'version',
'--help': 'help',
'-h': 'help',
'--doctor': 'doctor',
'--sync': 'sync',
'--cleanup': 'cleanup',
'--setup': 'setup',
};
const normalizedFirstArg = commandAliases[firstArg] || firstArg;
const earlyCommandHandlers: Record<string, () => Promise<void>> = {
version: async () => handleVersionCommand(),
help: async () => handleHelpCommand(),
'--install': async () => handleInstallCommand(),
'--uninstall': async () => handleUninstallCommand(),
'--shell-completion': async () => handleShellCompletionCommand(args.slice(1)),
'-sc': async () => handleShellCompletionCommand(args.slice(1)),
doctor: async () => handleDoctorCommand(args.slice(1)),
sync: async () => handleSyncCommand(),
cleanup: async () => {
const { handleCleanupCommand } = await import('./commands/cleanup-command');
await handleCleanupCommand(args.slice(1));
},
auth: async () => {
const AuthCommandsModule = await import('./auth/auth-commands');
const AuthCommands = AuthCommandsModule.default;
const authCommands = new AuthCommands();
await authCommands.route(args.slice(1));
},
api: async () => {
const { handleApiCommand } = await import('./commands/api-command');
await handleApiCommand(args.slice(1));
},
cliproxy: async () => {
const { handleCliproxyCommand } = await import('./commands/cliproxy-command');
await handleCliproxyCommand(args.slice(1));
},
config: async () => {
const { handleConfigCommand } = await import('./commands/config-command');
await handleConfigCommand(args.slice(1));
},
tokens: async () => {
const { handleTokensCommand } = await import('./commands/tokens-command');
const exitCode = await handleTokensCommand(args.slice(1));
process.exit(exitCode);
},
persist: async () => {
const { handlePersistCommand } = await import('./commands/persist-command');
await handlePersistCommand(args.slice(1));
},
env: async () => {
const { handleEnvCommand } = await import('./commands/env-command');
await handleEnvCommand(args.slice(1));
},
setup: async () => {
const { handleSetupCommand } = await import('./commands/setup-command');
await handleSetupCommand(args.slice(1));
},
cursor: async () => {
const { handleCursorCommand } = await import('./commands/cursor-command');
const exitCode = await handleCursorCommand(args.slice(1));
process.exit(exitCode);
},
};
const earlyCommandHandler = earlyCommandHandlers[normalizedFirstArg];
if (earlyCommandHandler) {
await earlyCommandHandler();
if (await tryHandleRootCommand(args)) {
return;
}
@@ -669,15 +349,6 @@ async function main(): Promise<void> {
process.exit(1);
}
// GLMT always requires Claude target because it depends on embedded proxy flow.
if (profileInfo.type === 'settings' && profileInfo.name === 'glmt') {
console.error(fail(`${targetAdapter.displayName} does not support GLMT proxy profiles`));
console.error(
info('Use --target claude for glmt, or switch to a direct API profile (glm/km)')
);
process.exit(1);
}
if (profileInfo.type === 'default') {
if (!targetAdapter.supportsProfileType('default')) {
console.error(fail(`${targetAdapter.displayName} does not support default profile mode`));
@@ -998,18 +669,29 @@ async function main(): Promise<void> {
);
}
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
const expandedSettingsPath = profileInfo.settingsPath
? expandPath(profileInfo.settingsPath)
: getSettingsPath(profileInfo.name);
const settings = loadSettings(expandedSettingsPath);
const rawSettingsEnv = profileInfo.env ?? settings.env ?? {};
const isDeprecatedGlmtProfile = isDeprecatedGlmtProfileName(profileInfo.name);
const glmtNormalization = isDeprecatedGlmtProfile
? normalizeDeprecatedGlmtEnv(rawSettingsEnv)
: null;
const settingsEnv = glmtNormalization?.env ?? rawSettingsEnv;
// Pre-flight validation for GLM/GLMT/MiniMax profiles
if (profileInfo.name === 'glm' || profileInfo.name === 'glmt') {
const preflightSettingsPath = getSettingsPath(profileInfo.name);
const preflightSettings = loadSettings(preflightSettingsPath);
const apiKey = preflightSettings.env?.['ANTHROPIC_AUTH_TOKEN'];
if (glmtNormalization) {
for (const message of glmtNormalization.warnings) {
console.error(warn(message));
}
}
// Pre-flight validation for Z.AI-compatible profiles.
if (profileInfo.name === 'glm' || isDeprecatedGlmtProfile) {
const apiKey = settingsEnv['ANTHROPIC_AUTH_TOKEN'];
if (apiKey) {
const validation = await validateGlmKey(
apiKey,
preflightSettings.env?.['ANTHROPIC_BASE_URL']
);
const validation = await validateGlmKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']);
if (!validation.valid) {
console.error('');
@@ -1026,15 +708,10 @@ async function main(): Promise<void> {
}
if (profileInfo.name === 'mm') {
const preflightSettingsPath = getSettingsPath(profileInfo.name);
const preflightSettings = loadSettings(preflightSettingsPath);
const apiKey = preflightSettings.env?.['ANTHROPIC_AUTH_TOKEN'];
const apiKey = settingsEnv['ANTHROPIC_AUTH_TOKEN'];
if (apiKey) {
const validation = await validateMiniMaxKey(
apiKey,
preflightSettings.env?.['ANTHROPIC_BASE_URL']
);
const validation = await validateMiniMaxKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']);
if (!validation.valid) {
console.error('');
@@ -1052,10 +729,8 @@ async function main(): Promise<void> {
// Pre-flight validation for Anthropic direct profiles (ANTHROPIC_API_KEY + no BASE_URL)
{
const preflightSettingsPath = getSettingsPath(profileInfo.name);
const preflightSettings = loadSettings(preflightSettingsPath);
const anthropicApiKey = preflightSettings.env?.['ANTHROPIC_API_KEY'];
const hasBaseUrl = !!preflightSettings.env?.['ANTHROPIC_BASE_URL'];
const anthropicApiKey = settingsEnv['ANTHROPIC_API_KEY'];
const hasBaseUrl = !!settingsEnv['ANTHROPIC_BASE_URL'];
if (anthropicApiKey && !hasBaseUrl) {
const validation = await validateAnthropicKey(anthropicApiKey);
if (!validation.valid) {
@@ -1074,89 +749,60 @@ async function main(): Promise<void> {
}
}
// Check if this is GLMT profile (requires proxy)
if (profileInfo.name === 'glmt') {
if (resolvedTarget !== 'claude') {
console.error(
fail(`${targetAdapter?.displayName || 'Target'} does not support GLMT proxy profiles`)
);
console.error(
info('Use --target claude for glmt, or switch to a direct API profile (glm/km)')
);
const webSearchEnv = getWebSearchHookEnv();
const imageAnalysisEnv = getImageAnalysisHookEnv(profileInfo.name);
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
const globalEnvConfig = getGlobalEnvConfig();
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
// Log global env injection for visibility (debug mode only)
if (globalEnvConfig.enabled && Object.keys(globalEnv).length > 0 && process.env.CCS_DEBUG) {
const envNames = Object.keys(globalEnv).join(', ');
console.error(info(`Global env: ${envNames}`));
}
// Explicitly inject effective settings env vars so stale ANTHROPIC_*
// values from prior sessions cannot leak into the active profile.
const envVars: NodeJS.ProcessEnv = {
...globalEnv,
...settingsEnv,
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
...webSearchEnv,
...imageAnalysisEnv,
CCS_PROFILE_TYPE: 'settings',
};
// Dispatch through target adapter for non-claude targets
if (resolvedTarget !== 'claude') {
const adapter = targetAdapter;
if (!adapter) {
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
process.exit(1);
}
// GLMT FLOW: Settings-based with embedded proxy for thinking support
await execClaudeWithProxy(
claudeCli,
profileInfo.name,
remainingArgs,
inheritedClaudeConfigDir
);
} else {
// EXISTING FLOW: Settings-based profile (glm)
// Use --settings flag (backward compatible)
const expandedSettingsPath = profileInfo.settingsPath
? expandPath(profileInfo.settingsPath)
: getSettingsPath(profileInfo.name);
const webSearchEnv = getWebSearchHookEnv();
const imageAnalysisEnv = getImageAnalysisHookEnv(profileInfo.name);
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
const globalEnvConfig = getGlobalEnvConfig();
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
// Log global env injection for visibility (debug mode only)
if (globalEnvConfig.enabled && Object.keys(globalEnv).length > 0 && process.env.CCS_DEBUG) {
const envNames = Object.keys(globalEnv).join(', ');
console.error(info(`Global env: ${envNames}`));
}
// 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
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
...webSearchEnv,
...imageAnalysisEnv,
CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider
};
// Dispatch through target adapter for non-claude targets
if (resolvedTarget !== 'claude') {
const adapter = targetAdapter;
if (!adapter) {
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
process.exit(1);
}
const directAnthropicBaseUrl =
settingsEnv['ANTHROPIC_BASE_URL'] ||
(settingsEnv['ANTHROPIC_API_KEY'] ? 'https://api.anthropic.com' : '');
const creds: TargetCredentials = {
profile: profileInfo.name,
const directAnthropicBaseUrl =
settingsEnv['ANTHROPIC_BASE_URL'] ||
(settingsEnv['ANTHROPIC_API_KEY'] ? 'https://api.anthropic.com' : '');
const creds: TargetCredentials = {
profile: profileInfo.name,
baseUrl: directAnthropicBaseUrl,
apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || settingsEnv['ANTHROPIC_API_KEY'] || '',
model: settingsEnv['ANTHROPIC_MODEL'],
provider: resolveDroidProvider({
provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'],
baseUrl: directAnthropicBaseUrl,
apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || settingsEnv['ANTHROPIC_API_KEY'] || '',
model: settingsEnv['ANTHROPIC_MODEL'],
provider: resolveDroidProvider({
provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'],
baseUrl: directAnthropicBaseUrl,
model: settingsEnv['ANTHROPIC_MODEL'],
}),
reasoningOverride: droidReasoningOverride,
};
await adapter.prepareCredentials(creds);
const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs);
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
return;
}
execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs], envVars);
}),
reasoningOverride: droidReasoningOverride,
envVars,
};
await adapter.prepareCredentials(creds);
const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs);
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
return;
}
execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs], envVars);
} else if (profileInfo.type === 'account') {
// NEW FLOW: Account-based profile (work, personal)
// All platforms: Use instance isolation with CLAUDE_CONFIG_DIR
+91
View File
@@ -0,0 +1,91 @@
import { getDefaultAccount } from './account-manager';
import { fetchCodexQuota } from './quota-fetcher-codex';
import { getCachedQuota, setCachedQuota } from './quota-response-cache';
import type { CodexQuotaResult } from './quota-types';
import { updateSettingsModel } from './services/variant-settings';
import { info, warn } from '../utils/ui';
export type CodexPlanType = CodexQuotaResult['planType'];
const FREE_SAFE_DEFAULT_MODEL = 'gpt-5-codex';
const FREE_SAFE_FAST_MODEL = 'gpt-5-codex-mini';
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i;
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
const FREE_PLAN_FALLBACKS = new Map<string, string>([
['gpt-5.3-codex', FREE_SAFE_DEFAULT_MODEL],
['gpt-5.3-codex-spark', FREE_SAFE_FAST_MODEL],
['gpt-5.4', FREE_SAFE_DEFAULT_MODEL],
]);
function normalizeCodexModelId(model: string): string {
return model
.trim()
.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '')
.replace(CODEX_PAREN_SUFFIX_REGEX, '')
.replace(CODEX_EFFORT_SUFFIX_REGEX, '')
.trim()
.toLowerCase();
}
export function getDefaultCodexModel(): string {
return FREE_SAFE_DEFAULT_MODEL;
}
export function getFreePlanFallbackCodexModel(model: string): string | null {
return FREE_PLAN_FALLBACKS.get(normalizeCodexModelId(model)) ?? null;
}
export async function reconcileCodexModelForActivePlan(options: {
settingsPath: string;
currentModel: string | undefined;
verbose: boolean;
}): Promise<void> {
const { settingsPath, currentModel, verbose } = options;
if (!currentModel) return;
const fallbackModel = getFreePlanFallbackCodexModel(currentModel);
if (!fallbackModel) return;
const defaultAccount = getDefaultAccount('codex');
if (!defaultAccount) {
console.error(
warn(
`Configured Codex model "${normalizeCodexModelId(currentModel)}" may require a paid Codex plan. ` +
`If startup fails, switch to "${fallbackModel}" with "ccs codex --config".`
)
);
return;
}
const cachedQuota = getCachedQuota<CodexQuotaResult>('codex', defaultAccount.id);
const quota = cachedQuota ?? (await fetchCodexQuota(defaultAccount.id, verbose));
if (!cachedQuota) {
setCachedQuota('codex', defaultAccount.id, quota);
}
if (quota.planType === 'free') {
updateSettingsModel(settingsPath, fallbackModel, 'codex', {
rewriteHaikuModel: (haikuModel) => getFreePlanFallbackCodexModel(haikuModel) ?? haikuModel,
});
console.error(
info(
`Codex free plan detected. Switched unsupported model "${normalizeCodexModelId(currentModel)}" ` +
`to "${fallbackModel}".`
)
);
return;
}
if (quota.planType) {
return;
}
console.error(
warn(
`Could not verify Codex plan for model "${normalizeCodexModelId(currentModel)}". ` +
`If startup fails with model_not_supported, switch to "${fallbackModel}" via "ccs codex --config".`
)
);
}
+9
View File
@@ -32,6 +32,7 @@ import { isAuthenticated } from '../auth-handler';
import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS, ExecutorConfig } from '../types';
import { DEFAULT_BACKEND } from '../platform-detector';
import { configureProviderModel, getCurrentModel } from '../model-config';
import { reconcileCodexModelForActivePlan } from '../codex-plan-compatibility';
import { resolveProxyConfig, PROXY_CLI_FLAGS } from '../proxy-config-resolver';
import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from '../model-catalog';
import { CodexReasoningProxy } from '../codex-reasoning-proxy';
@@ -731,6 +732,14 @@ export async function execClaudeWithCLIProxy(
// 6. Ensure user settings file exists
ensureProviderSettings(provider);
if (provider === 'codex' && !cfg.isComposite && !skipLocalAuth) {
await reconcileCodexModelForActivePlan({
settingsPath: cfg.customSettingsPath || getProviderSettingsPath(provider),
currentModel: getCurrentModel(provider, cfg.customSettingsPath),
verbose,
});
}
// Local proxy mode: generate config, spawn/join proxy, track session
let proxy: ChildProcess | null = null;
let configPath: string | undefined;
+5
View File
@@ -100,6 +100,11 @@ export {
configureProviderModel,
showCurrentConfig,
} from './model-config';
export {
getDefaultCodexModel,
getFreePlanFallbackCodexModel,
reconcileCodexModelForActivePlan,
} from './codex-plan-compatibility';
// Executor
export { execClaudeWithCLIProxy, isPortAvailable, findAvailablePort } from './cliproxy-executor';
+81 -12
View File
@@ -147,15 +147,59 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
codex: {
provider: 'codex',
displayName: 'Copilot Codex',
defaultModel: 'gpt-5.3-codex',
defaultModel: 'gpt-5-codex',
models: [
{
id: 'gpt-5.3-codex',
name: 'GPT-5.3 Codex',
description: 'Supports up to xhigh effort',
id: 'gpt-5-codex',
name: 'GPT-5 Codex',
description: 'Cross-plan safe Codex default',
thinking: {
type: 'levels',
levels: ['medium', 'high', 'xhigh'],
levels: ['low', 'medium', 'high'],
maxLevel: 'high',
dynamicAllowed: false,
},
},
{
id: 'gpt-5-codex-mini',
name: 'GPT-5 Codex Mini',
description: 'Faster and cheaper Codex option',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high'],
maxLevel: 'high',
dynamicAllowed: false,
},
},
{
id: 'gpt-5-mini',
name: 'GPT-5 Mini',
description: 'Legacy mini model ID kept for backwards compatibility',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high'],
maxLevel: 'high',
dynamicAllowed: false,
},
},
{
id: 'gpt-5.1-codex-mini',
name: 'GPT-5.1 Codex Mini',
description: 'Legacy fast Codex mini model',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high'],
maxLevel: 'high',
dynamicAllowed: false,
},
},
{
id: 'gpt-5.1-codex-max',
name: 'GPT-5.1 Codex Max',
description: 'Higher-effort Codex model with xhigh support',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high', 'xhigh'],
maxLevel: 'xhigh',
dynamicAllowed: false,
},
@@ -163,22 +207,47 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
{
id: 'gpt-5.2-codex',
name: 'GPT-5.2 Codex',
description: 'Previous stable Codex model',
description: 'Cross-plan Codex model with xhigh support',
thinking: {
type: 'levels',
levels: ['medium', 'high', 'xhigh'],
levels: ['low', 'medium', 'high', 'xhigh'],
maxLevel: 'xhigh',
dynamicAllowed: false,
},
},
{
id: 'gpt-5-mini',
name: 'GPT-5 Mini',
description: 'Capped at high effort (no xhigh)',
id: 'gpt-5.3-codex',
name: 'GPT-5.3 Codex',
tier: 'pro',
description: 'Paid Codex plans only',
thinking: {
type: 'levels',
levels: ['medium', 'high'],
maxLevel: 'high',
levels: ['low', 'medium', 'high', 'xhigh'],
maxLevel: 'xhigh',
dynamicAllowed: false,
},
},
{
id: 'gpt-5.3-codex-spark',
name: 'GPT-5.3 Codex Spark',
tier: 'pro',
description: 'Paid Codex plans only, ultra-fast coding model',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high', 'xhigh'],
maxLevel: 'xhigh',
dynamicAllowed: false,
},
},
{
id: 'gpt-5.4',
name: 'GPT-5.4',
tier: 'pro',
description: 'Paid Codex plans only, latest GPT-5 family model',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high', 'xhigh'],
maxLevel: 'xhigh',
dynamicAllowed: false,
},
},
+2 -4
View File
@@ -146,9 +146,7 @@ export async function configureProviderModel(
console.error(header(`Configure ${catalog.displayName} Model`));
console.error('');
console.error(dim(' Select which model to use for this provider.'));
console.error(
dim(' Models marked [Paid Tier] require a paid Google account (not free tier).')
);
console.error(dim(' Models marked [Pro]/[Ultra] require a paid provider plan.'));
console.error(dim(' Models marked [DEPRECATED] are not recommended for use.'));
console.error('');
@@ -274,7 +272,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise<voi
console.error('');
console.error(bold('Available models:'));
console.error(dim(' [Paid Tier] = Requires paid Google account (not free tier)'));
console.error(dim(' [Pro]/[Ultra] = Requires a paid provider plan'));
console.error(dim(' [DEPRECATED] = Not recommended for use'));
console.error('');
catalog.models.forEach((m) => {
+8 -2
View File
@@ -290,7 +290,10 @@ export function deleteSettingsFile(settingsPath: string): boolean {
export function updateSettingsModel(
settingsPath: string,
model: string,
provider?: CLIProxyProfileName
provider?: CLIProxyProfileName,
options?: {
rewriteHaikuModel?: (model: string) => string;
}
): void {
const fileName = path.basename(settingsPath);
if (fileName.startsWith('composite-')) {
@@ -316,10 +319,13 @@ export function updateSettingsModel(
settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = normalizedModel;
settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = normalizedModel;
if (provider === 'codex' && settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) {
settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = canonicalizeModelForProvider(
const normalizedHaikuModel = canonicalizeModelForProvider(
provider,
settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
);
settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = options?.rewriteHaikuModel
? options.rewriteHaikuModel(normalizedHaikuModel)
: normalizedHaikuModel;
}
} else {
// Clear model settings to use defaults
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
import { copyApiProfile } from '../../api/services';
import { fail, info, initUI, ok, warn } from '../../utils/ui';
import { InteractivePrompt } from '../../utils/prompt';
import { exitOnApiCommandErrors, parseApiCommandArgs } from './shared';
export async function handleApiCopyCommand(args: string[]): Promise<void> {
await initUI();
const parsedArgs = parseApiCommandArgs(args, { maxPositionals: 2 });
exitOnApiCommandErrors(parsedArgs.errors);
const source = parsedArgs.positionals[0];
let destination = parsedArgs.positionals[1];
if (!source) {
console.log(fail('Source profile is required. Usage: ccs api copy <source> <destination>'));
process.exit(1);
}
if (!destination) {
destination = await InteractivePrompt.input('Destination profile name');
}
if (!parsedArgs.yes) {
const confirmed = await InteractivePrompt.confirm(
`Copy profile "${source}" to "${destination}"?`,
{ default: true }
);
if (!confirmed) {
console.log(info('Cancelled'));
process.exit(0);
}
}
const result = copyApiProfile(source, destination, {
target: parsedArgs.target,
force: parsedArgs.force,
});
if (!result.success) {
console.log(fail(result.error || 'Failed to copy profile'));
process.exit(1);
}
console.log(ok(`Profile copied: ${source} -> ${destination}`));
result.warnings?.forEach((warningMessage) => console.log(warn(warningMessage)));
console.log('');
}
+337
View File
@@ -0,0 +1,337 @@
import {
apiProfileExists,
createApiProfile,
getPresetById,
getPresetIds,
getUrlWarning,
isOpenRouterUrl,
isUsingUnifiedConfig,
pickOpenRouterModel,
sanitizeBaseUrl,
validateApiName,
validateUrl,
type ModelMapping,
type ProviderPreset,
} from '../../api/services';
import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync';
import type { TargetType } from '../../targets/target-adapter';
import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui';
import { InteractivePrompt } from '../../utils/prompt';
import { exitOnApiCommandErrors, parseApiCommandArgs } from './shared';
function resolvePresetOrExit(presetId?: string): ProviderPreset | null {
if (!presetId) {
return null;
}
const preset = getPresetById(presetId);
if (preset) {
return preset;
}
console.log(fail(`Unknown preset: ${presetId}`));
console.log('');
console.log('Available presets:');
getPresetIds().forEach((id) => console.log(` - ${id}`));
process.exit(1);
}
function showPresetDeprecationNotice(presetId?: string): void {
if ((presetId || '').trim().toLowerCase() !== 'glmt') {
return;
}
console.log(warn('Preset "glmt" is deprecated and now maps to the direct "glm" preset.'));
console.log(dim(' Z.AI models already expose thinking natively, so CCS no longer needs GLMT.'));
console.log(dim(' Update scripts/docs to: ccs api create --preset glm'));
console.log('');
}
async function resolveProfileName(
providedName: string | undefined,
preset: ProviderPreset | null
): Promise<string> {
const name = providedName || preset?.defaultProfileName;
if (!name) {
return InteractivePrompt.input('API name', {
validate: validateApiName,
});
}
const error = validateApiName(name);
if (error) {
console.log(fail(error));
process.exit(1);
}
return name;
}
async function resolveBaseUrl(
providedBaseUrl: string | undefined,
preset: ProviderPreset | null
): Promise<string> {
let baseUrl = providedBaseUrl ?? preset?.baseUrl ?? '';
if (!baseUrl && !preset) {
baseUrl = await InteractivePrompt.input(
'API Base URL (e.g., https://api.example.com/v1 - without /chat/completions)',
{ validate: validateUrl }
);
} else if (!preset) {
const error = validateUrl(baseUrl);
if (error) {
console.log(fail(error));
process.exit(1);
}
}
if (!preset) {
const urlWarning = getUrlWarning(baseUrl);
if (urlWarning) {
console.log('');
console.log(warn(urlWarning));
const continueAnyway = await InteractivePrompt.confirm('Continue with this URL anyway?', {
default: false,
});
if (!continueAnyway) {
baseUrl = await InteractivePrompt.input('API Base URL', {
validate: validateUrl,
default: sanitizeBaseUrl(baseUrl),
});
}
}
return baseUrl;
}
console.log(info(`Using preset: ${preset.name}`));
console.log(dim(` ${preset.description}`));
console.log(
dim(
preset.baseUrl
? ` Base URL: ${preset.baseUrl}`
: ' Auth: Native Anthropic API (x-api-key header)'
)
);
console.log('');
return baseUrl;
}
async function resolveApiKey(
providedApiKey: string | undefined,
preset: ProviderPreset | null
): Promise<string> {
if (preset?.requiresApiKey === false) {
if (providedApiKey) {
console.log(dim(`Note: Using provided API key for ${preset.name} (optional)`));
return providedApiKey;
}
console.log(info(`No API key required for ${preset.name}`));
return preset.apiKeyPlaceholder || preset.id;
}
if (providedApiKey) {
return providedApiKey;
}
const keyPrompt = preset?.apiKeyHint ? `API Key (${preset.apiKeyHint})` : 'API Key';
const apiKey = await InteractivePrompt.password(keyPrompt);
if (!apiKey) {
console.log(fail('API key is required'));
process.exit(1);
}
return apiKey;
}
async function resolveModelConfiguration(
baseUrl: string,
preset: ProviderPreset | null,
providedModel: string | undefined,
yes: boolean | undefined
): Promise<{ model: string; models: ModelMapping }> {
let openRouterModel: string | undefined;
let openRouterTierMapping: { opus?: string; sonnet?: string; haiku?: string } | undefined;
if (isOpenRouterUrl(baseUrl) && !providedModel) {
console.log('');
console.log(info('OpenRouter detected!'));
const useInteractive = await InteractivePrompt.confirm('Browse models interactively?', {
default: true,
});
if (useInteractive) {
const selection = await pickOpenRouterModel();
if (selection) {
openRouterModel = selection.model;
openRouterTierMapping = selection.tierMapping;
}
}
console.log('');
console.log(dim('Note: For OpenRouter, ANTHROPIC_API_KEY should be empty.'));
}
const defaultModel = preset?.defaultModel || 'claude-sonnet-4-6';
let model = providedModel || openRouterModel || preset?.defaultModel;
if (!model && !yes && !preset) {
model = await InteractivePrompt.input('Default model (ANTHROPIC_MODEL)', {
default: defaultModel,
});
}
model = model || defaultModel;
let opusModel = openRouterTierMapping?.opus || model;
let sonnetModel = openRouterTierMapping?.sonnet || model;
let haikuModel = openRouterTierMapping?.haiku || model;
const shouldPromptForMapping = !yes && !openRouterTierMapping && !preset;
if (shouldPromptForMapping) {
let wantCustomMapping = model !== defaultModel;
if (!wantCustomMapping) {
console.log('');
console.log(dim('Some API proxies route different model types to different backends.'));
wantCustomMapping = await InteractivePrompt.confirm(
'Configure different models for Opus/Sonnet/Haiku?',
{ default: false }
);
}
if (wantCustomMapping) {
console.log('');
console.log(dim('Leave blank to use the default model for each tier.'));
opusModel =
(await InteractivePrompt.input('Opus model (ANTHROPIC_DEFAULT_OPUS_MODEL)', {
default: model,
})) || model;
sonnetModel =
(await InteractivePrompt.input('Sonnet model (ANTHROPIC_DEFAULT_SONNET_MODEL)', {
default: model,
})) || model;
haikuModel =
(await InteractivePrompt.input('Haiku model (ANTHROPIC_DEFAULT_HAIKU_MODEL)', {
default: model,
})) || model;
}
}
return {
model,
models: {
default: model,
opus: opusModel,
sonnet: sonnetModel,
haiku: haikuModel,
},
};
}
async function resolveDefaultTarget(
providedTarget: TargetType | undefined,
yes: boolean | undefined
): Promise<TargetType> {
if (providedTarget) {
return providedTarget;
}
if (yes) {
return 'claude';
}
const useDroidByDefault = await InteractivePrompt.confirm(
'Set default target to Factory Droid for this profile?',
{ default: false }
);
return useDroidByDefault ? 'droid' : 'claude';
}
export async function handleApiCreateCommand(args: string[]): Promise<void> {
await initUI();
const parsedArgs = parseApiCommandArgs(args);
exitOnApiCommandErrors(parsedArgs.errors);
console.log(header('Create API Profile'));
console.log('');
showPresetDeprecationNotice(parsedArgs.preset);
const preset = resolvePresetOrExit(parsedArgs.preset);
const name = await resolveProfileName(parsedArgs.name, preset);
if (apiProfileExists(name) && !parsedArgs.force) {
console.log(fail(`API '${name}' already exists`));
console.log(` Use ${color('--force', 'command')} to overwrite`);
process.exit(1);
}
let baseUrl = await resolveBaseUrl(parsedArgs.baseUrl, preset);
if (baseUrl && baseUrl.includes('api.anthropic.com') && !preset) {
console.log('');
console.log(info('Anthropic Direct API detected. Base URL will be omitted for native auth.'));
baseUrl = '';
}
const apiKey = await resolveApiKey(parsedArgs.apiKey, preset);
const { model, models } = await resolveModelConfiguration(
baseUrl,
preset,
parsedArgs.model,
parsedArgs.yes
);
const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes);
console.log('');
console.log(info('Creating API profile...'));
const result = createApiProfile(name, baseUrl || '', apiKey, models, target);
if (!result.success) {
console.log(fail(`Failed to create API profile: ${result.error}`));
process.exit(1);
}
try {
syncToLocalConfig();
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.log(`[i] Auto-sync to CLIProxy config skipped: ${message}`);
}
const hasCustomMapping =
models.opus !== model || models.sonnet !== model || models.haiku !== model;
let details =
`API: ${name}\n` +
`Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` +
`Settings: ${result.settingsFile}\n` +
`Base URL: ${baseUrl}\n` +
`Model: ${model}\n` +
`Target: ${target}`;
if (hasCustomMapping) {
details +=
`\n\nModel Mapping:\n` +
` Opus: ${models.opus}\n` +
` Sonnet: ${models.sonnet}\n` +
` Haiku: ${models.haiku}`;
}
console.log('');
console.log(infoBox(details, 'API Profile Created'));
console.log('');
console.log(header('Usage'));
if (target === 'droid') {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}`
);
console.log(
` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}`
);
console.log(
` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}`
);
} else {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}`
);
console.log(
` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}`
);
}
console.log('');
console.log(header('Edit Settings'));
console.log(` ${dim('To modify env vars later:')}`);
console.log(` ${color(`nano ${result.settingsFile.replace('~', '$HOME')}`, 'command')}`);
console.log('');
}
@@ -0,0 +1,79 @@
import { discoverApiProfileOrphans, registerApiProfileOrphans } from '../../api/services';
import { color, fail, header, info, initUI, ok, table, warn } from '../../utils/ui';
import { hasAnyFlag } from '../arg-extractor';
import { API_KNOWN_FLAGS, collectUnexpectedApiArgs, parseOptionalTargetFlag } from './shared';
export async function handleApiDiscoverCommand(args: string[]): Promise<void> {
await initUI();
const register = hasAnyFlag(args, ['--register']);
const jsonOutput = hasAnyFlag(args, ['--json']);
const force = hasAnyFlag(args, ['--force']);
const targetParsed = parseOptionalTargetFlag(args, [...API_KNOWN_FLAGS, '--register', '--json']);
if (targetParsed.errors.length > 0) {
targetParsed.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exit(1);
}
const syntax = collectUnexpectedApiArgs(targetParsed.remainingArgs, {
knownFlags: ['--register', '--json', '--force'],
maxPositionals: 0,
});
if (syntax.errors.length > 0) {
syntax.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exit(1);
}
const result = discoverApiProfileOrphans();
if (jsonOutput) {
console.log(JSON.stringify(result, null, 2));
return;
}
console.log(header('Discover Orphan API Profiles'));
console.log('');
if (result.orphans.length === 0) {
console.log(ok('No orphan settings files found.'));
console.log('');
return;
}
console.log(
table(
result.orphans.map((orphan) => {
const status = orphan.validation.valid ? color('[OK]', 'success') : color('[X]', 'error');
const issueSummary =
orphan.validation.issues.length > 0
? orphan.validation.issues[0].message
: 'Ready to register';
return [orphan.name, status, issueSummary];
}),
{
head: ['Profile', 'Status', 'Validation'],
colWidths: [20, 10, 64],
}
)
);
console.log('');
if (!register) {
console.log(info('To register discovered profiles:'));
console.log(` ${color('ccs api discover --register', 'command')}`);
console.log('');
return;
}
const registration = registerApiProfileOrphans({
target: targetParsed.target || 'claude',
force,
});
console.log(ok(`Registered: ${registration.registered.length}`));
if (registration.skipped.length > 0) {
console.log(warn(`Skipped: ${registration.skipped.length}`));
registration.skipped.forEach((item) => {
console.log(` - ${item.name}: ${item.reason}`);
});
}
console.log('');
}
@@ -0,0 +1,52 @@
import * as fs from 'fs';
import * as path from 'path';
import { exportApiProfile } from '../../api/services';
import { fail, initUI, ok, warn } from '../../utils/ui';
import { extractOption, hasAnyFlag } from '../arg-extractor';
import { collectUnexpectedApiArgs } from './shared';
export async function handleApiExportCommand(args: string[]): Promise<void> {
await initUI();
const includeSecrets = hasAnyFlag(args, ['--include-secrets']);
const outExtracted = extractOption(args, ['--out'], {
allowDashValue: true,
allowLongDashValue: true,
knownFlags: ['--out', '--include-secrets'],
});
if (outExtracted.found && (outExtracted.missingValue || !outExtracted.value)) {
console.log(fail('Missing value for --out'));
process.exit(1);
}
const syntax = collectUnexpectedApiArgs(outExtracted.remainingArgs, {
knownFlags: ['--include-secrets'],
maxPositionals: 1,
});
if (syntax.errors.length > 0) {
syntax.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exit(1);
}
const name = syntax.positionals[0];
if (!name) {
console.log(fail('Profile name is required. Usage: ccs api export <name> [--out <file>]'));
process.exit(1);
}
const result = exportApiProfile(name, includeSecrets);
if (!result.success || !result.bundle) {
console.log(fail(result.error || 'Failed to export profile'));
process.exit(1);
}
const outputPath = path.resolve(outExtracted.value || `${name}.ccs-profile.json`);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, JSON.stringify(result.bundle, null, 2) + '\n', 'utf8');
console.log(ok(`Profile exported to: ${outputPath}`));
if (result.redacted) {
console.log(warn('Token was redacted in export. Use --include-secrets to include it.'));
}
console.log('');
}
+117
View File
@@ -0,0 +1,117 @@
import {
PROVIDER_PRESETS,
getPresetAliases,
getPresetIds,
type ProviderPreset,
} from '../../api/services';
import { color, dim, fail, header, initUI, subheader } from '../../utils/ui';
import { sanitizeHelpText } from './shared';
function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string {
const presetId = sanitizeHelpText(preset.id) || 'unknown';
const paddedId = presetId.padEnd(idWidth);
const presetName = sanitizeHelpText(preset.name) || 'Unknown preset';
const presetDescription = sanitizeHelpText(preset.description) || 'No description';
return ` ${color(paddedId, 'command')} ${presetName} - ${presetDescription}`;
}
export async function showApiCommandHelp(): Promise<void> {
await initUI();
const presetIds = getPresetIds()
.map((id) => sanitizeHelpText(id))
.filter(Boolean);
const presetAliases = getPresetAliases();
const presetIdWidth = Math.max(0, ...presetIds.map((id) => id.length)) + 2;
console.log(header('CCS API Management'));
console.log('');
console.log(subheader('Usage'));
console.log(` ${color('ccs api', 'command')} <command> [options]`);
console.log('');
console.log(subheader('Commands'));
console.log(` ${color('create [name]', 'command')} Create new API profile (interactive)`);
console.log(` ${color('list', 'command')} List all API profiles`);
console.log(
` ${color('discover', 'command')} Discover orphan *.settings.json and register`
);
console.log(` ${color('copy <src> <dest>', 'command')} Duplicate API profile settings + config`);
console.log(
` ${color('export <name>', 'command')} Export profile bundle for cross-device transfer`
);
console.log(
` ${color('import <file>', 'command')} Import profile bundle and register profile`
);
console.log(` ${color('remove <name>', 'command')} Remove an API profile`);
console.log('');
console.log(subheader('Options'));
console.log(
` ${color('--preset <id>', 'command')} Use provider preset (${presetIds.join(', ')})`
);
console.log(` ${color('--base-url <url>', 'command')} API base URL (create)`);
console.log(` ${color('--api-key <key>', 'command')} API key (create)`);
console.log(` ${color('--model <model>', 'command')} Default model (create)`);
console.log(
` ${color('--target <cli>', 'command')} Default target: claude or droid (create)`
);
console.log(` ${color('--register', 'command')} Register discovered orphan settings`);
console.log(` ${color('--json', 'command')} JSON output for discover command`);
console.log(` ${color('--out <file>', 'command')} Export bundle output path`);
console.log(` ${color('--include-secrets', 'command')} Include token in export bundle`);
console.log(` ${color('--name <name>', 'command')} Override profile name during import`);
console.log(
` ${color('--force', 'command')} Overwrite existing or bypass validation (create/discover/copy/import)`
);
console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`);
console.log('');
console.log(subheader('Provider Presets'));
PROVIDER_PRESETS.forEach((preset) => console.log(renderPresetHelpLine(preset, presetIdWidth)));
Object.entries(presetAliases).forEach(([alias, canonical]) => {
const safeAlias = sanitizeHelpText(alias);
const safeCanonical = sanitizeHelpText(canonical);
console.log(
` ${dim(`Legacy alias: --preset ${safeAlias} (auto-mapped to ${safeCanonical})`)}`
);
});
console.log('');
console.log(subheader('Examples'));
console.log(` ${dim('# Interactive wizard')}`);
console.log(` ${color('ccs api create', 'command')}`);
console.log('');
console.log(` ${dim('# Quick setup with preset')}`);
console.log(` ${color('ccs api create --preset anthropic', 'command')}`);
console.log(` ${color('ccs api create --preset openrouter', 'command')}`);
console.log(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`);
console.log(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`);
console.log(` ${color('ccs api create --preset glm', 'command')}`);
console.log('');
console.log(` ${dim('# Create with name')}`);
console.log(` ${color('ccs api create myapi', 'command')}`);
console.log(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`);
console.log('');
console.log(` ${dim('# Remove API profile')}`);
console.log(` ${color('ccs api remove myapi', 'command')}`);
console.log('');
console.log(` ${dim('# Discover and register orphan settings files')}`);
console.log(` ${color('ccs api discover', 'command')}`);
console.log(` ${color('ccs api discover --register', 'command')}`);
console.log('');
console.log(` ${dim('# Duplicate an existing API profile')}`);
console.log(` ${color('ccs api copy glm glm-backup', 'command')}`);
console.log('');
console.log(` ${dim('# Export and import across devices')}`);
console.log(` ${color('ccs api export glm --out ./glm.ccs-profile.json', 'command')}`);
console.log(` ${color('ccs api import ./glm.ccs-profile.json', 'command')}`);
console.log('');
console.log(` ${dim('# Show all API profiles')}`);
console.log(` ${color('ccs api list', 'command')}`);
console.log('');
}
export async function showUnknownApiCommand(command: string): Promise<void> {
await initUI();
console.log(fail(`Unknown command: ${command}`));
console.log('');
console.log('Run for help:');
console.log(` ${color('ccs api --help', 'command')}`);
process.exit(1);
}
@@ -0,0 +1,98 @@
import * as fs from 'fs';
import { importApiProfileBundle, type ProfileValidationIssue } from '../../api/services';
import { color, fail, info, initUI, ok, warn } from '../../utils/ui';
import { InteractivePrompt } from '../../utils/prompt';
import { extractOption, hasAnyFlag } from '../arg-extractor';
import { collectUnexpectedApiArgs, parseOptionalTargetFlag } from './shared';
function renderValidationIssue(issue: ProfileValidationIssue): void {
const indicator = issue.level === 'error' ? color('[X]', 'error') : color('[!]', 'warning');
console.log(`${indicator} ${issue.message}`);
}
export async function handleApiImportCommand(args: string[]): Promise<void> {
await initUI();
const force = hasAnyFlag(args, ['--force']);
const yes = hasAnyFlag(args, ['--yes', '-y']);
const nameExtracted = extractOption(args, ['--name'], {
knownFlags: ['--name', '--target', '--force', '--yes', '-y'],
});
if (nameExtracted.found && (nameExtracted.missingValue || !nameExtracted.value)) {
console.log(fail('Missing value for --name'));
process.exit(1);
}
const targetParsed = parseOptionalTargetFlag(nameExtracted.remainingArgs, [
'--name',
'--target',
'--force',
'--yes',
'-y',
]);
if (targetParsed.errors.length > 0) {
targetParsed.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exit(1);
}
const syntax = collectUnexpectedApiArgs(targetParsed.remainingArgs, {
knownFlags: ['--force', '--yes', '-y'],
maxPositionals: 1,
});
if (syntax.errors.length > 0) {
syntax.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exit(1);
}
const importPath = syntax.positionals[0];
if (!importPath) {
console.log(
fail('Import file path is required. Usage: ccs api import <file> [--name <new-name>]')
);
process.exit(1);
}
if (!fs.existsSync(importPath)) {
console.log(fail(`File not found: ${importPath}`));
process.exit(1);
}
let bundle: unknown;
try {
bundle = JSON.parse(fs.readFileSync(importPath, 'utf8'));
} catch (error) {
console.log(fail(`Invalid JSON file: ${(error as Error).message}`));
process.exit(1);
}
if (!yes) {
const confirmed = await InteractivePrompt.confirm(
`Import profile bundle from "${importPath}"?`,
{
default: true,
}
);
if (!confirmed) {
console.log(info('Cancelled'));
process.exit(0);
}
}
const result = importApiProfileBundle(bundle, {
name: nameExtracted.value,
target: targetParsed.target,
force,
});
if (!result.success) {
console.log(fail(result.error || 'Failed to import profile'));
if (result.validation?.issues?.length) {
console.log('');
result.validation.issues.forEach(renderValidationIssue);
}
process.exit(1);
}
console.log(ok(`Profile imported: ${result.name}`));
result.warnings?.forEach((warningMessage) => console.log(warn(warningMessage)));
console.log('');
}
+31
View File
@@ -0,0 +1,31 @@
import { dispatchNamedCommand, type NamedCommandRoute } from '../named-command-router';
import { handleApiCopyCommand } from './copy-command';
import { handleApiCreateCommand } from './create-command';
import { handleApiDiscoverCommand } from './discover-command';
import { handleApiExportCommand } from './export-command';
import { showApiCommandHelp, showUnknownApiCommand } from './help';
import { handleApiImportCommand } from './import-command';
import { handleApiListCommand } from './list-command';
import { handleApiRemoveCommand } from './remove-command';
export { parseApiCommandArgs } from './shared';
const API_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
{ name: 'create', handle: handleApiCreateCommand },
{ name: 'list', handle: handleApiListCommand },
{ name: 'discover', handle: handleApiDiscoverCommand },
{ name: 'copy', handle: handleApiCopyCommand },
{ name: 'export', handle: handleApiExportCommand },
{ name: 'import', handle: handleApiImportCommand },
{ name: 'remove', aliases: ['delete', 'rm'], handle: handleApiRemoveCommand },
];
export async function handleApiCommand(args: string[]): Promise<void> {
await dispatchNamedCommand({
args,
routes: API_COMMAND_ROUTES,
onHelp: showApiCommandHelp,
onUnknown: showUnknownApiCommand,
allowEmptyHelp: true,
});
}
+62
View File
@@ -0,0 +1,62 @@
import { listApiProfiles, isUsingUnifiedConfig } from '../../api/services';
import { color, dim, fail, header, initUI, subheader, table, warn } from '../../utils/ui';
import { collectUnexpectedApiArgs } from './shared';
export async function handleApiListCommand(args: string[] = []): Promise<void> {
await initUI();
const syntax = collectUnexpectedApiArgs(args, {
maxPositionals: 0,
});
if (syntax.errors.length > 0) {
syntax.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exit(1);
}
console.log(header('CCS API Profiles'));
console.log('');
const { profiles, variants } = listApiProfiles();
if (profiles.length === 0) {
console.log(warn('No API profiles configured'));
console.log('');
console.log('To create an API profile:');
console.log(` ${color('ccs api create', 'command')}`);
console.log('');
return;
}
const rows = profiles.map((profile) => {
const status = profile.isConfigured ? color('[OK]', 'success') : color('[!]', 'warning');
return [profile.name, profile.target, profile.settingsPath, status];
});
console.log(
table(rows, {
head: ['API', 'Target', isUsingUnifiedConfig() ? 'Config' : 'Settings File', 'Status'],
colWidths: isUsingUnifiedConfig() ? [15, 10, 20, 10] : [15, 10, 35, 10],
})
);
console.log('');
if (variants.length > 0) {
console.log(subheader('CLIProxy Variants'));
console.log(
table(
variants.map((variant) => [
variant.name,
variant.provider,
variant.target,
variant.settings,
]),
{
head: ['Variant', 'Provider', 'Target', 'Settings'],
colWidths: [15, 12, 10, 28],
}
)
);
console.log('');
}
console.log(dim(`Total: ${profiles.length} API profile(s)`));
console.log('');
}
@@ -0,0 +1,65 @@
import { getApiProfileNames, isUsingUnifiedConfig, removeApiProfile } from '../../api/services';
import { color, fail, header, info, initUI, ok, warn } from '../../utils/ui';
import { InteractivePrompt } from '../../utils/prompt';
import { exitOnApiCommandErrors, parseApiCommandArgs } from './shared';
export async function handleApiRemoveCommand(args: string[]): Promise<void> {
await initUI();
const parsedArgs = parseApiCommandArgs(args);
exitOnApiCommandErrors(parsedArgs.errors);
const apis = getApiProfileNames();
if (apis.length === 0) {
console.log(warn('No API profiles to remove'));
process.exit(0);
}
let name = parsedArgs.name;
if (!name) {
console.log(header('Remove API Profile'));
console.log('');
console.log('Available APIs:');
apis.forEach((api, index) => console.log(` ${index + 1}. ${api}`));
console.log('');
name = await InteractivePrompt.input('API name to remove', {
validate: (value) => {
if (!value) return 'API name is required';
if (!apis.includes(value)) return `API '${value}' not found`;
return null;
},
});
}
if (!apis.includes(name)) {
console.log(fail(`API '${name}' not found`));
console.log('');
console.log('Available APIs:');
apis.forEach((api) => console.log(` - ${api}`));
process.exit(1);
}
console.log('');
console.log(`API '${color(name, 'command')}' will be removed.`);
console.log(` Settings: ~/.ccs/${name}.settings.json`);
if (isUsingUnifiedConfig()) {
console.log(' Config: ~/.ccs/config.yaml');
}
console.log('');
const confirmed =
parsedArgs.yes ||
(await InteractivePrompt.confirm('Delete this API profile?', { default: false }));
if (!confirmed) {
console.log(info('Cancelled'));
process.exit(0);
}
const result = removeApiProfile(name);
if (!result.success) {
console.log(fail(`Failed to remove API profile: ${result.error}`));
process.exit(1);
}
console.log(ok(`API profile removed: ${name}`));
console.log('');
}
+249
View File
@@ -0,0 +1,249 @@
import type { TargetType } from '../../targets/target-adapter';
import { fail } from '../../utils/ui';
import { extractOption, hasAnyFlag, scanCommandArgs } from '../arg-extractor';
export interface ApiCommandArgs {
name?: string;
positionals: string[];
baseUrl?: string;
apiKey?: string;
model?: string;
preset?: string;
target?: TargetType;
force?: boolean;
yes?: boolean;
errors: string[];
}
export const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y'] as const;
export const API_VALUE_FLAGS = [
'--base-url',
'--api-key',
'--model',
'--preset',
'--target',
] as const;
export const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS];
const API_VALUE_FLAG_SET = new Set<string>(API_VALUE_FLAGS);
export interface ParseApiCommandArgsOptions {
maxPositionals?: number;
}
export function sanitizeHelpText(value: string): string {
return value
.replace(/[\r\n\t]+/g, ' ')
.replace(/[\x00-\x1f\x7f]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function applyRepeatedOption(
args: string[],
flags: readonly string[],
onValue: (value: string) => void,
onMissing: () => void,
allowDashValue = false
): string[] {
let remaining = [...args];
while (true) {
const extracted = extractOption(remaining, flags, {
allowDashValue,
knownFlags: API_KNOWN_FLAGS,
});
if (!extracted.found) {
return remaining;
}
if (extracted.missingValue || !extracted.value) {
onMissing();
} else {
onValue(extracted.value);
}
remaining = extracted.remainingArgs;
}
}
export function extractPositionalArgs(args: string[]): string[] {
const positionals: string[] = [];
for (let i = 0; i < args.length; i++) {
const token = args[i];
if (token === '--') {
positionals.push(...args.slice(i + 1));
break;
}
if (token.startsWith('-')) {
if (!token.includes('=') && API_VALUE_FLAG_SET.has(token)) {
const next = args[i + 1];
if (next && !next.startsWith('-')) {
i++;
}
}
continue;
}
positionals.push(token);
}
return positionals;
}
function parseTargetValue(value: string): TargetType | null {
const normalized = value.trim().toLowerCase();
if (normalized === 'claude' || normalized === 'droid') {
return normalized;
}
return null;
}
export function parseOptionalTargetFlag(
args: string[],
knownFlags: readonly string[]
): { target?: TargetType; remainingArgs: string[]; errors: string[] } {
const extracted = extractOption(args, ['--target'], {
knownFlags,
});
if (!extracted.found) {
return { remainingArgs: args, errors: [] };
}
if (extracted.missingValue || !extracted.value) {
return { remainingArgs: extracted.remainingArgs, errors: ['Missing value for --target'] };
}
const target = parseTargetValue(extracted.value);
if (!target) {
return {
remainingArgs: extracted.remainingArgs,
errors: [`Invalid --target value "${extracted.value}". Use: claude or droid`],
};
}
return { target, remainingArgs: extracted.remainingArgs, errors: [] };
}
export function collectUnexpectedApiArgs(
args: string[],
options: {
knownFlags?: readonly string[];
maxPositionals: number;
}
): { positionals: string[]; errors: string[] } {
const scanned = scanCommandArgs(args, {
knownFlags: options.knownFlags ?? [],
});
const errors = scanned.unknownFlags.map((flag) => `Unknown option: ${flag}`);
if (scanned.positionals.length > options.maxPositionals) {
errors.push(
`Unexpected arguments: ${scanned.positionals.slice(options.maxPositionals).join(' ')}`
);
}
return {
positionals: scanned.positionals,
errors,
};
}
export function parseApiCommandArgs(
args: string[],
options: ParseApiCommandArgsOptions = {}
): ApiCommandArgs {
const result: ApiCommandArgs = {
positionals: [],
force: hasAnyFlag(args, ['--force']),
yes: hasAnyFlag(args, ['--yes', '-y']),
errors: [],
};
let remaining = [...args];
remaining = applyRepeatedOption(
remaining,
['--base-url'],
(value) => {
result.baseUrl = value;
},
() => {
result.errors.push('Missing value for --base-url');
},
false
);
remaining = applyRepeatedOption(
remaining,
['--api-key'],
(value) => {
result.apiKey = value;
},
() => {
result.errors.push('Missing value for --api-key');
},
false
);
remaining = applyRepeatedOption(
remaining,
['--model'],
(value) => {
result.model = value;
},
() => {
result.errors.push('Missing value for --model');
},
true
);
remaining = applyRepeatedOption(
remaining,
['--preset'],
(value) => {
result.preset = value;
},
() => {
result.errors.push('Missing value for --preset');
},
false
);
remaining = applyRepeatedOption(
remaining,
['--target'],
(value) => {
const target = parseTargetValue(value);
if (!target) {
result.errors.push(`Invalid --target value "${value}". Use: claude or droid`);
return;
}
result.target = target;
},
() => {
result.errors.push('Missing value for --target');
},
false
);
const unexpected = collectUnexpectedApiArgs(remaining, {
knownFlags: API_BOOLEAN_FLAGS,
maxPositionals: options.maxPositionals ?? 1,
});
result.positionals = unexpected.positionals;
result.name = unexpected.positionals[0];
result.errors.push(...unexpected.errors);
return result;
}
export function exitOnApiCommandErrors(errors: string[]): void {
if (errors.length === 0) {
return;
}
errors.forEach((errorMessage) => {
console.log(fail(errorMessage));
});
process.exit(1);
}
+80 -1
View File
@@ -15,6 +15,11 @@ export interface ExtractOptionOptions {
* Useful for model IDs or other arbitrary strings.
*/
allowDashValue?: boolean;
/**
* Allow values that start with "--" when allowDashValue is enabled.
* Keep this opt-in narrow so unknown long flags are still rejected by default.
*/
allowLongDashValue?: boolean;
/**
* Known flags for the current command. Used with allowDashValue to avoid
* treating a real flag token as a value.
@@ -22,6 +27,17 @@ export interface ExtractOptionOptions {
knownFlags?: readonly string[];
}
export interface ScanCommandArgsOptions {
knownFlags: readonly string[];
valueFlags?: readonly string[];
allowDashValue?: boolean;
}
export interface ScannedCommandArgs {
positionals: string[];
unknownFlags: string[];
}
function findInlineOption(arg: string, flag: string): string | undefined {
const prefix = `${flag}=`;
return arg.startsWith(prefix) ? arg.slice(prefix.length) : undefined;
@@ -35,6 +51,17 @@ function isKnownFlagToken(token: string, knownFlags: readonly string[] | undefin
return knownFlags.some((flag) => token === flag || token.startsWith(`${flag}=`));
}
function findMatchingFlagToken(
token: string,
knownFlags: readonly string[] | undefined
): string | undefined {
if (!knownFlags || knownFlags.length === 0) {
return undefined;
}
return knownFlags.find((flag) => token === flag || token.startsWith(`${flag}=`));
}
/**
* Extract a single-value option and remove it from args.
* Supports `--flag value` and `--flag=value` forms.
@@ -46,6 +73,7 @@ export function extractOption(
): ExtractedOption {
const remaining = [...args];
const allowDashValue = options.allowDashValue ?? false;
const allowLongDashValue = options.allowLongDashValue ?? false;
for (let i = 0; i < remaining.length; i++) {
const token = remaining[i];
@@ -59,8 +87,11 @@ export function extractOption(
}
const nextLooksLikeFlag = next.startsWith('-');
const nextLooksLikeLongFlag = next.startsWith('--');
const nextIsKnownFlag = isKnownFlagToken(next, options.knownFlags);
if (nextLooksLikeFlag && (!allowDashValue || nextIsKnownFlag)) {
const canTreatAsDashValue =
allowDashValue && !nextIsKnownFlag && (!nextLooksLikeLongFlag || allowLongDashValue);
if (nextLooksLikeFlag && !canTreatAsDashValue) {
remaining.splice(i, 1);
return { found: true, missingValue: true, remainingArgs: remaining };
}
@@ -112,3 +143,51 @@ export function hasAnyFlag(args: string[], flags: readonly string[]): boolean {
})
);
}
export function scanCommandArgs(
args: string[],
options: ScanCommandArgsOptions
): ScannedCommandArgs {
const positionals: string[] = [];
const unknownFlags: string[] = [];
const allowDashValue = options.allowDashValue ?? false;
const valueFlags = new Set(options.valueFlags ?? []);
for (let i = 0; i < args.length; i++) {
const token = args[i];
if (token === '--') {
positionals.push(...args.slice(i + 1));
break;
}
if (token === '-' || !token.startsWith('-')) {
positionals.push(token);
continue;
}
const matchedFlag = findMatchingFlagToken(token, options.knownFlags);
if (!matchedFlag) {
unknownFlags.push(token);
continue;
}
if (!valueFlags.has(matchedFlag) || token.includes('=')) {
continue;
}
const next = args[i + 1];
if (!next) {
continue;
}
const nextLooksLikeFlag = next.startsWith('-');
const nextLooksLikeLongFlag = next.startsWith('--');
const nextIsKnownFlag = isKnownFlagToken(next, options.knownFlags);
if (!nextLooksLikeFlag || (allowDashValue && !nextIsKnownFlag && !nextLooksLikeLongFlag)) {
i++;
}
}
return { positionals, unknownFlags };
}
+43 -24
View File
@@ -8,12 +8,47 @@
*/
import { initUI, header, subheader, color, dim, fail } from '../../utils/ui';
import { dispatchNamedCommand, type NamedCommandRoute } from '../named-command-router';
// Import command handlers
import { handleSetup } from './setup-command';
import { handleShow } from './show-command';
import { handleDisable } from './disable-command';
async function ensureNoConfigAuthArgs(command: string, args: string[]): Promise<void> {
if (args.length === 0) {
return;
}
await initUI();
console.log(fail(`Unexpected arguments for "config auth ${command}": ${args.join(' ')}`));
console.log('');
console.log('Run for help:');
console.log(` ${color('ccs config auth --help', 'command')}`);
process.exit(1);
}
function createZeroArgConfigAuthRoute(
name: string,
handler: () => Promise<unknown>,
aliases?: readonly string[]
): NamedCommandRoute {
return {
name,
aliases,
handle: async (args) => {
await ensureNoConfigAuthArgs(name, args);
await handler();
},
};
}
const CONFIG_AUTH_ROUTES: readonly NamedCommandRoute[] = [
createZeroArgConfigAuthRoute('setup', handleSetup),
createZeroArgConfigAuthRoute('show', handleShow, ['status']),
createZeroArgConfigAuthRoute('disable', handleDisable),
];
/**
* Show help for config auth commands
*/
@@ -58,36 +93,20 @@ async function showHelp(): Promise<void> {
* Route config auth command to appropriate handler
*/
export async function handleConfigAuthCommand(args: string[]): Promise<void> {
// Default to help if no subcommand
if (args.length === 0 || args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
await showHelp();
return;
}
const command = args[0];
switch (command) {
case 'setup':
await handleSetup();
break;
case 'show':
case 'status':
await handleShow();
break;
case 'disable':
await handleDisable();
break;
default:
await dispatchNamedCommand({
args,
routes: CONFIG_AUTH_ROUTES,
onHelp: showHelp,
allowEmptyHelp: true,
onUnknown: async (command) => {
await initUI();
console.log(fail(`Unknown command: ${command}`));
console.log('');
console.log('Run for help:');
console.log(` ${color('ccs config auth --help', 'command')}`);
process.exit(1);
}
},
});
}
// Re-export types
+128
View File
@@ -0,0 +1,128 @@
import { extractOption, hasAnyFlag, scanCommandArgs } from './arg-extractor';
const CONFIG_COMMAND_FLAGS = ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'] as const;
export interface ConfigCommandOptions {
port?: number;
host?: string;
hostProvided: boolean;
dev: boolean;
}
export interface ConfigCommandParseResult {
help: boolean;
error?: string;
options: ConfigCommandOptions;
}
function formatUnexpectedArgsError(tokens: string[]): string {
return `Unexpected arguments: ${tokens.join(' ')}`;
}
export function parseConfigCommandArgs(args: string[]): ConfigCommandParseResult {
const options: ConfigCommandOptions = {
hostProvided: false,
dev: false,
};
if (hasAnyFlag(args, ['--help', '-h'])) {
return { help: true, options };
}
const portOption = extractOption(args, ['--port', '-p'], {
knownFlags: CONFIG_COMMAND_FLAGS,
});
if (portOption.found) {
if (portOption.missingValue || !portOption.value) {
return { help: false, error: 'Invalid port number', options };
}
const port = parseInt(portOption.value, 10);
if (Number.isNaN(port) || port <= 0 || port >= 65536) {
return { help: false, error: 'Invalid port number', options };
}
options.port = port;
}
const hostOption = extractOption(portOption.remainingArgs, ['--host', '-H'], {
knownFlags: CONFIG_COMMAND_FLAGS,
});
if (hostOption.found) {
const host = hostOption.value?.trim();
if (hostOption.missingValue || !host) {
return { help: false, error: 'Invalid host value', options };
}
options.host = host;
options.hostProvided = true;
}
options.dev = hasAnyFlag(hostOption.remainingArgs, ['--dev']);
const unexpected = scanCommandArgs(hostOption.remainingArgs, {
knownFlags: ['--dev'],
});
const unexpectedTokens = [...unexpected.unknownFlags, ...unexpected.positionals];
if (unexpectedTokens.length > 0) {
return {
help: false,
error: formatUnexpectedArgsError(unexpectedTokens),
options,
};
}
return { help: false, options };
}
export function showConfigCommandHelp(): void {
console.log('');
console.log('Usage: ccs config [command] [options]');
console.log('');
console.log('Open web-based configuration dashboard');
console.log('Includes a dedicated Claude IDE Extension page for VS Code-compatible hosts.');
console.log('');
console.log('Commands:');
console.log(' auth Manage dashboard authentication');
console.log(' auth setup Configure username and password');
console.log(' auth show Display current auth status');
console.log(' auth disable Disable authentication');
console.log('');
console.log(' image-analysis Manage image analysis settings');
console.log(' --enable Enable image analysis via CLIProxy');
console.log(' --disable Disable image analysis');
console.log(' --timeout <s> Set analysis timeout (seconds)');
console.log(' --set-model <p> <m> Set model for provider');
console.log('');
console.log(' Claude IDE Extension');
console.log(' Dashboard page Generate copy-ready setup for VS Code, Cursor, Windsurf');
console.log(' Shared settings Shows preferred ~/.claude/settings.json setup');
console.log(' IDE-local JSON Shows extension-specific environmentVariables snippets');
console.log('');
console.log(' thinking Manage thinking/reasoning settings');
console.log(' --mode <mode> Set mode (auto, off, manual)');
console.log(' --override <l> Set persistent override level');
console.log(' --clear-override Remove persistent override');
console.log(' --tier <t> <l> Set tier default level');
console.log(' --provider-override <p> <t> <l> Set provider tier override');
console.log(' --clear-provider-override <p> [t] Remove provider override');
console.log('');
console.log('Options:');
console.log(' --port, -p PORT Specify server port (default: auto-detect)');
console.log(' --host, -H HOST Bind dashboard server host (default: system default)');
console.log(' --dev Development mode with Vite HMR');
console.log(' --help, -h Show this help message');
console.log('');
console.log('Examples:');
console.log(' ccs config Auto-detect available port');
console.log(' ccs config --port 3000 Use specific port');
console.log(' ccs config --host 0.0.0.0 Force all-interface binding for remote devices');
console.log(' ccs config --host 127.0.0.1 Restrict dashboard to this machine');
console.log(' ccs config --dev Development mode with hot reload');
console.log(' ccs config auth setup Configure dashboard login');
console.log(' ccs config image-analysis Show image settings');
console.log(' ccs config image-analysis --enable Enable feature');
console.log(' ccs config thinking Show thinking settings');
console.log(' ccs config thinking --mode auto Set auto mode');
console.log('');
}
+105 -114
View File
@@ -3,7 +3,7 @@
*
* Launches web-based configuration dashboard.
* Ensures CLIProxy service is running for dashboard features.
* Usage: ccs config [--port PORT] [--dev]
* Usage: ccs config [--port PORT] [--host HOST] [--dev]
*/
import getPort from 'get-port';
@@ -12,127 +12,73 @@ import { startServer } from '../web-server';
import { setupGracefulShutdown } from '../web-server/shutdown';
import { ensureCliproxyService } from '../cliproxy/service-manager';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator';
import { getDashboardAuthConfig } from '../config/unified-config-loader';
import { initUI, header, ok, info, warn, fail } from '../utils/ui';
import { extractOption, hasAnyFlag } from './arg-extractor';
import { resolveNamedCommand, type NamedCommandRoute } from './named-command-router';
import {
isLoopbackHost,
isWildcardHost,
normalizeDashboardHost,
resolveDashboardUrls,
} from './config-dashboard-host';
import { parseConfigCommandArgs, showConfigCommandHelp } from './config-command-options';
interface ConfigOptions {
port?: number;
dev?: boolean;
}
/**
* Parse command line arguments
*/
function parseArgs(args: string[]): ConfigOptions {
const result: ConfigOptions = {};
if (hasAnyFlag(args, ['--help', '-h'])) {
showHelp();
process.exit(0);
}
const portOption = extractOption(args, ['--port', '-p']);
if (portOption.found) {
if (portOption.missingValue || !portOption.value) {
console.error(fail('Invalid port number'));
process.exit(1);
}
const port = parseInt(portOption.value, 10);
if (!isNaN(port) && port > 0 && port < 65536) {
result.port = port;
} else {
console.error(fail('Invalid port number'));
process.exit(1);
}
}
result.dev = hasAnyFlag(args, ['--dev']);
return result;
}
/**
* Show help message
*/
function showHelp(): void {
console.log('');
console.log('Usage: ccs config [command] [options]');
console.log('');
console.log('Open web-based configuration dashboard');
console.log('Includes a dedicated Claude IDE Extension page for VS Code-compatible hosts.');
console.log('');
console.log('Commands:');
console.log(' auth Manage dashboard authentication');
console.log(' auth setup Configure username and password');
console.log(' auth show Display current auth status');
console.log(' auth disable Disable authentication');
console.log('');
console.log(' image-analysis Manage image analysis settings');
console.log(' --enable Enable image analysis via CLIProxy');
console.log(' --disable Disable image analysis');
console.log(' --timeout <s> Set analysis timeout (seconds)');
console.log(' --set-model <p> <m> Set model for provider');
console.log('');
console.log(' Claude IDE Extension');
console.log(' Dashboard page Generate copy-ready setup for VS Code, Cursor, Windsurf');
console.log(' Shared settings Shows preferred ~/.claude/settings.json setup');
console.log(' IDE-local JSON Shows extension-specific environmentVariables snippets');
console.log('');
console.log(' thinking Manage thinking/reasoning settings');
console.log(' --mode <mode> Set mode (auto, off, manual)');
console.log(' --override <l> Set persistent override level');
console.log(' --clear-override Remove persistent override');
console.log(' --tier <t> <l> Set tier default level');
console.log(' --provider-override <p> <t> <l> Set provider tier override');
console.log(' --clear-provider-override <p> [t] Remove provider override');
console.log('');
console.log('Options:');
console.log(' --port, -p PORT Specify server port (default: auto-detect)');
console.log(' --dev Development mode with Vite HMR');
console.log(' --help, -h Show this help message');
console.log('');
console.log('Examples:');
console.log(' ccs config Auto-detect available port');
console.log(' ccs config --port 3000 Use specific port');
console.log(' ccs config --dev Development mode with hot reload');
console.log(' ccs config auth setup Configure dashboard login');
console.log(' ccs config Open dashboard, then choose Claude IDE Extension');
console.log(' ccs config image-analysis Show image settings');
console.log(' ccs config image-analysis --enable Enable feature');
console.log(' ccs config thinking Show thinking settings');
console.log(' ccs config thinking --mode auto Set auto mode');
console.log('');
}
const CONFIG_SUBCOMMAND_ROUTES: readonly NamedCommandRoute[] = [
{
name: 'auth',
handle: async (args) => {
const { handleConfigAuthCommand } = await import('./config-auth');
await handleConfigAuthCommand(args);
},
},
{
name: 'image-analysis',
handle: async (args) => {
const { handleConfigImageAnalysisCommand } = await import('./config-image-analysis-command');
await handleConfigImageAnalysisCommand(args);
},
},
{
name: 'thinking',
handle: async (args) => {
const { handleConfigThinkingCommand } = await import('./config-thinking-command');
await handleConfigThinkingCommand(args);
},
},
];
/**
* Handle config command
*/
export async function handleConfigCommand(args: string[]): Promise<void> {
// Route subcommands before dashboard launch
if (args[0] === 'auth') {
const { handleConfigAuthCommand } = await import('./config-auth');
await handleConfigAuthCommand(args.slice(1));
return;
if (args.length === 1 && args[0] === 'help') {
await initUI();
showConfigCommandHelp();
process.exit(0);
}
// Route image-analysis subcommand
if (args[0] === 'image-analysis') {
const { handleConfigImageAnalysisCommand } = await import('./config-image-analysis-command');
await handleConfigImageAnalysisCommand(args.slice(1));
return;
}
// Route thinking subcommand
if (args[0] === 'thinking') {
const { handleConfigThinkingCommand } = await import('./config-thinking-command');
await handleConfigThinkingCommand(args.slice(1));
const subcommand = args[0]?.startsWith('-')
? undefined
: resolveNamedCommand(args[0], CONFIG_SUBCOMMAND_ROUTES);
if (subcommand) {
await subcommand.handle(args.slice(1));
return;
}
await initUI();
const options = parseArgs(args);
const verbose = options.dev || false;
const parsed = parseConfigCommandArgs(args);
if (parsed.help) {
showConfigCommandHelp();
process.exit(0);
}
if (parsed.error) {
console.error(fail(parsed.error));
process.exit(1);
}
const options = parsed.options;
const verbose = options.dev;
console.log(header('CCS Config Dashboard'));
console.log('');
@@ -167,28 +113,62 @@ export async function handleConfigCommand(args: string[]): Promise<void> {
try {
// Start server
const { server, wss, cleanup } = await startServer({ port, dev: options.dev });
const serverOptions: Parameters<typeof startServer>[0] = {
port,
dev: options.dev,
};
if (options.hostProvided && options.host) {
serverOptions.host = normalizeDashboardHost(options.host);
}
const { server, wss, cleanup } = await startServer(serverOptions);
// Setup graceful shutdown
setupGracefulShutdown(server, wss, cleanup);
const url = `http://localhost:${port}`;
const urls = resolveDashboardUrls(resolveServerBindHost(server) ?? options.host, port);
const shouldWarnAboutExposure = urls.bindHost ? !isLoopbackHost(urls.bindHost) : false;
if (options.dev) {
console.log(ok(`Dev Server: ${url}`));
console.log(ok(`Dev Server: ${urls.browserUrl}`));
console.log('');
console.log(info('HMR enabled - UI changes will hot-reload'));
} else {
console.log(ok(`Dashboard: ${url}`));
console.log(ok(`Dashboard: ${urls.browserUrl}`));
}
if (shouldWarnAboutExposure && urls.bindHost) {
console.log(info(`Bind host: ${urls.bindHost}`));
if (urls.networkUrls?.length === 1) {
console.log(info(`Network URL: ${urls.networkUrls[0]}`));
} else if (urls.networkUrls && urls.networkUrls.length > 1) {
console.log(info('Network URLs:'));
for (const networkUrl of urls.networkUrls) {
console.log(info(` ${networkUrl}`));
}
}
}
if (shouldWarnAboutExposure && urls.bindHost) {
const authConfig = getDashboardAuthConfig();
console.log(
warn('Dashboard may be reachable from other devices that can connect to this machine.')
);
if (!authConfig.enabled) {
console.log(info('Protect it before sharing: ccs config auth setup'));
}
if (isWildcardHost(urls.bindHost) && !urls.networkUrls?.length) {
console.log(info('Use your machine IP or hostname from the other device.'));
}
}
console.log('');
// Open browser
try {
await open(url, { wait: false });
await open(urls.browserUrl, { wait: false });
console.log(info('Browser opened automatically'));
} catch {
console.log(info(`Open manually: ${url}`));
console.log(info(`Open manually: ${urls.browserUrl}`));
}
console.log('');
@@ -198,3 +178,14 @@ export async function handleConfigCommand(args: string[]): Promise<void> {
process.exit(1);
}
}
function resolveServerBindHost(server: {
address(): string | { address: string } | null;
}): string | undefined {
const address = server.address();
if (!address || typeof address === 'string') {
return undefined;
}
return address.address;
}
+106
View File
@@ -0,0 +1,106 @@
import * as os from 'os';
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
const WILDCARD_HOSTS = new Set(['0.0.0.0', '::', '[::]']);
interface NetworkInterfaceCandidate {
address: string;
family: string | number;
internal: boolean;
}
type NetworkInterfacesMap = Record<string, NetworkInterfaceCandidate[] | undefined>;
export interface DashboardUrls {
bindHost?: string;
browserUrl: string;
networkUrls?: string[];
}
export function isLoopbackHost(host: string): boolean {
return LOOPBACK_HOSTS.has(normalizeDashboardHost(host)?.toLowerCase() ?? '');
}
export function isWildcardHost(host: string): boolean {
return WILDCARD_HOSTS.has(normalizeDashboardHost(host)?.toLowerCase() ?? '');
}
export function normalizeDashboardHost(host: string | undefined): string | undefined {
if (!host) {
return undefined;
}
const trimmedHost = host.trim();
if (!trimmedHost) {
return undefined;
}
if (trimmedHost.startsWith('[') && trimmedHost.endsWith(']') && trimmedHost.includes(':')) {
return trimmedHost.slice(1, -1);
}
return trimmedHost;
}
export function resolveDashboardUrls(
host: string | undefined,
port: number,
networkInterfaces: NetworkInterfacesMap = os.networkInterfaces()
): DashboardUrls {
const bindHost = normalizeDashboardHost(host);
if (!bindHost) {
return {
browserUrl: `http://localhost:${port}`,
};
}
if (isWildcardHost(bindHost)) {
return {
bindHost,
browserUrl: `http://localhost:${port}`,
networkUrls: getExternalIpv4Urls(port, networkInterfaces),
};
}
return {
bindHost,
browserUrl: `http://${formatHostForUrl(bindHost)}:${port}`,
};
}
function getExternalIpv4Urls(
port: number,
networkInterfaces: NetworkInterfacesMap
): string[] | undefined {
const urls: string[] = [];
const seen = new Set<string>();
for (const interfaceName of Object.keys(networkInterfaces)) {
const candidates = networkInterfaces[interfaceName];
if (!candidates) {
continue;
}
for (const candidate of candidates) {
const family =
typeof candidate.family === 'string' ? candidate.family : String(candidate.family);
if (family === 'IPv4' && !candidate.internal) {
const url = `http://${candidate.address}:${port}`;
if (!seen.has(url)) {
seen.add(url);
urls.push(url);
}
}
}
}
return urls.length > 0 ? urls : undefined;
}
function formatHostForUrl(host: string): string {
if (host.includes(':') && !host.startsWith('[') && !host.endsWith(']')) {
return `[${host}]`;
}
return host;
}
+5
View File
@@ -209,6 +209,11 @@ export async function handleEnvCommand(args: string[]): Promise<void> {
let envVars: Record<string, string>;
try {
const resolved = await resolveClaudeExtensionSetup(profile);
if (resolved.warnings.length > 0) {
for (const message of resolved.warnings) {
console.error(warn(message));
}
}
envVars = resolved.extensionEnv;
if (format === 'claude-extension') {
console.log(renderClaudeExtensionSettingsJson(resolved, ide));
+1 -1
View File
@@ -131,7 +131,6 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
[
['ccs', 'Use default Claude account'],
['ccs glm', 'GLM 5 (API key required)'],
['ccs glmt', 'GLM with thinking mode'],
['ccs km', 'Kimi for Coding (API key)'],
[
'ccs api create --preset alibaba-coding-plan',
@@ -314,6 +313,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs config thinking --mode auto', 'Set thinking mode'],
['ccs config thinking --clear-provider-override codex', 'Clear provider overrides'],
['ccs config --port 3000', 'Use specific port'],
['ccs config --host 0.0.0.0', 'Force all-interface binding for remote devices'],
['ccs persist <profile>', 'Write profile setup to ~/.claude/settings.json'],
['ccs persist --list-backups', 'List available settings.json backups'],
['ccs persist --restore', 'Restore settings.json from latest backup'],
+58
View File
@@ -0,0 +1,58 @@
export interface NamedCommandRoute {
name: string;
aliases?: readonly string[];
handle(args: string[]): Promise<void> | void;
}
interface DispatchNamedCommandOptions {
args: string[];
routes: readonly NamedCommandRoute[];
onUnknown(command: string): Promise<void> | void;
onHelp?: () => Promise<void> | void;
helpTokens?: readonly string[];
allowEmptyHelp?: boolean;
}
const DEFAULT_HELP_TOKENS = ['help', '--help', '-h'] as const;
export function resolveNamedCommand(
token: string | undefined,
routes: readonly NamedCommandRoute[]
): NamedCommandRoute | undefined {
if (!token) {
return undefined;
}
return routes.find((route) => route.name === token || route.aliases?.includes(token));
}
export async function dispatchNamedCommand(options: DispatchNamedCommandOptions): Promise<boolean> {
const { args, routes, onUnknown, onHelp, allowEmptyHelp = false } = options;
const helpTokens = options.helpTokens || DEFAULT_HELP_TOKENS;
const command = args[0];
if (!command) {
if (!allowEmptyHelp || !onHelp) {
return false;
}
await onHelp();
return true;
}
if (helpTokens.includes(command)) {
if (!onHelp) {
return false;
}
await onHelp();
return true;
}
const route = resolveNamedCommand(command, routes);
if (!route) {
await onUnknown(command);
return true;
}
await route.handle(args.slice(1));
return true;
}
+1 -1
View File
@@ -679,7 +679,7 @@ async function showHelp(): Promise<void> {
);
console.log('');
console.log(subheader('Supported Profile Types'));
console.log(` ${color('API profiles', 'command')} glm, glmt, km, custom API profiles`);
console.log(` ${color('API profiles', 'command')} glm, km, custom API profiles`);
console.log(` ${color('CLIProxy', 'command')} gemini, codex, agy, qwen, kiro, ghcp`);
console.log(` ${color('Copilot', 'command')} copilot (requires copilot-api daemon)`);
console.log(
+185
View File
@@ -0,0 +1,185 @@
import { resolveNamedCommand, type NamedCommandRoute } from './named-command-router';
async function printUpdateCommandHelp(): Promise<void> {
console.log('');
console.log('Usage: ccs update [options]');
console.log('');
console.log('Options:');
console.log(' --force Force reinstall current version');
console.log(' --beta, --dev Install from dev channel (unstable)');
console.log(' --help, -h Show this help message');
console.log('');
console.log('Examples:');
console.log(' ccs update Update to latest stable');
console.log(' ccs update --force Force reinstall');
console.log(' ccs update --beta Install dev channel');
console.log('');
}
const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
{
name: 'migrate',
aliases: ['--migrate'],
handle: async (args) => {
const { handleMigrateCommand, printMigrateHelp } = await import('./migrate-command');
if (args.includes('--help') || args.includes('-h')) {
printMigrateHelp();
return;
}
await handleMigrateCommand(args);
},
},
{
name: 'update',
aliases: ['--update'],
handle: async (args) => {
if (args.includes('--help') || args.includes('-h')) {
await printUpdateCommandHelp();
return;
}
const { handleUpdateCommand } = await import('./update-command');
await handleUpdateCommand({
force: args.includes('--force'),
beta: args.includes('--beta') || args.includes('--dev'),
});
},
},
{
name: 'version',
aliases: ['--version', '-v'],
handle: async () => {
const { handleVersionCommand } = await import('./version-command');
await handleVersionCommand();
},
},
{
name: 'help',
aliases: ['--help', '-h'],
handle: async () => {
const { handleHelpCommand } = await import('./help-command');
await handleHelpCommand();
},
},
{
name: '--install',
handle: async () => {
const { handleInstallCommand } = await import('./install-command');
await handleInstallCommand();
},
},
{
name: '--uninstall',
handle: async () => {
const { handleUninstallCommand } = await import('./install-command');
await handleUninstallCommand();
},
},
{
name: '--shell-completion',
aliases: ['-sc'],
handle: async (args) => {
const { handleShellCompletionCommand } = await import('./shell-completion-command');
await handleShellCompletionCommand(args);
},
},
{
name: 'doctor',
aliases: ['--doctor'],
handle: async (args) => {
const { handleDoctorCommand } = await import('./doctor-command');
await handleDoctorCommand(args);
},
},
{
name: 'sync',
aliases: ['--sync'],
handle: async () => {
const { handleSyncCommand } = await import('./sync-command');
await handleSyncCommand();
},
},
{
name: 'cleanup',
aliases: ['--cleanup'],
handle: async (args) => {
const { handleCleanupCommand } = await import('./cleanup-command');
await handleCleanupCommand(args);
},
},
{
name: 'auth',
handle: async (args) => {
const AuthCommandsModule = await import('../auth/auth-commands');
const AuthCommands = AuthCommandsModule.default;
const authCommands = new AuthCommands();
await authCommands.route(args);
},
},
{
name: 'api',
handle: async (args) => {
const { handleApiCommand } = await import('./api-command');
await handleApiCommand(args);
},
},
{
name: 'cliproxy',
handle: async (args) => {
const { handleCliproxyCommand } = await import('./cliproxy-command');
await handleCliproxyCommand(args);
},
},
{
name: 'config',
handle: async (args) => {
const { handleConfigCommand } = await import('./config-command');
await handleConfigCommand(args);
},
},
{
name: 'tokens',
handle: async (args) => {
const { handleTokensCommand } = await import('./tokens-command');
process.exit(await handleTokensCommand(args));
},
},
{
name: 'persist',
handle: async (args) => {
const { handlePersistCommand } = await import('./persist-command');
await handlePersistCommand(args);
},
},
{
name: 'env',
handle: async (args) => {
const { handleEnvCommand } = await import('./env-command');
await handleEnvCommand(args);
},
},
{
name: 'setup',
aliases: ['--setup'],
handle: async (args) => {
const { handleSetupCommand } = await import('./setup-command');
await handleSetupCommand(args);
},
},
{
name: 'cursor',
handle: async (args) => {
const { handleCursorCommand } = await import('./cursor-command');
process.exit(await handleCursorCommand(args));
},
},
];
export async function tryHandleRootCommand(args: string[]): Promise<boolean> {
const route = resolveNamedCommand(args[0], ROOT_COMMAND_ROUTES);
if (!route) {
return false;
}
await route.handle(args.slice(1));
return true;
}
+1 -1
View File
@@ -553,7 +553,7 @@ function generateYamlWithComments(config: UnifiedConfig): string {
// Profiles section
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Profiles: API-based providers (GLM, GLMT, Kimi, custom endpoints)');
lines.push('# Profiles: API-based providers (GLM, Kimi, custom endpoints)');
lines.push('# Each profile points to a *.settings.json file containing env vars.');
lines.push('# Edit the settings file directly to customize (ANTHROPIC_MAX_TOKENS, etc.)');
lines.push('# ----------------------------------------------------------------------------');
+249
View File
@@ -0,0 +1,249 @@
import { DeltaAccumulator } from '../glmt/delta-accumulator';
import { GlmtTransformer } from '../glmt/glmt-transformer';
import { SSEParser } from '../glmt/sse-parser';
import type { OpenAIResponse, SSEEvent } from '../glmt/pipeline';
const JSON_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor JSON response';
const STREAM_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor SSE response';
type ResponseHeaders = Headers | Record<string, string> | Array<[string, string]>;
interface AnthropicErrorPayload {
type: 'error';
error: {
type: string;
message: string;
};
}
function createAnthropicErrorPayload(type: string, message: string): AnthropicErrorPayload {
return {
type: 'error',
error: {
type,
message,
},
};
}
function formatErrorForLog(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
function logTranslationError(context: string, error: unknown): void {
console.error(`[cursor-anthropic-response] ${context}: ${formatErrorForLog(error)}`);
}
export function createAnthropicErrorResponse(
status: number,
type: string,
message: string,
headers?: ResponseHeaders
): Response {
const responseHeaders = new Headers(headers);
responseHeaders.set('Content-Type', 'application/json');
responseHeaders.delete('Content-Length');
return new Response(JSON.stringify(createAnthropicErrorPayload(type, message)), {
status,
headers: responseHeaders,
});
}
function formatSseEvent(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}
function hasTranslatableChoices(value: unknown): value is OpenAIResponse {
if (typeof value !== 'object' || value === null) {
return false;
}
const { choices } = value as OpenAIResponse;
if (!Array.isArray(choices) || choices.length === 0) {
return false;
}
const firstChoice = choices[0];
if (typeof firstChoice !== 'object' || firstChoice === null) {
return false;
}
const message = (firstChoice as { message?: unknown }).message;
return typeof message === 'object' && message !== null;
}
function isSyntheticTransformationFallback(value: unknown): boolean {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { id?: unknown }).id === 'string' &&
(value as { id: string }).id.startsWith('msg_error_')
);
}
async function createAnthropicErrorProxyResponse(response: Response): Promise<Response> {
const headers = new Headers(response.headers);
headers.delete('Content-Type');
headers.delete('Content-Length');
let type =
response.status === 401
? 'authentication_error'
: response.status === 429
? 'rate_limit_error'
: response.status >= 400 && response.status < 500
? 'invalid_request_error'
: 'api_error';
let message = `Cursor request failed with status ${response.status}`;
try {
const contentType = (response.headers.get('content-type') || '').toLowerCase();
if (contentType.includes('application/json')) {
const payload = (await response.json()) as {
error?: { type?: string; message?: string };
message?: string;
};
if (typeof payload?.error?.type === 'string' && payload.error.type.trim().length > 0) {
type = payload.error.type;
}
if (typeof payload?.error?.message === 'string' && payload.error.message.trim().length > 0) {
message = payload.error.message;
} else if (typeof payload?.message === 'string' && payload.message.trim().length > 0) {
message = payload.message;
}
} else {
const text = (await response.text()).trim();
if (text.length > 0) {
message = text;
}
}
} catch (error) {
logTranslationError('Failed to parse Cursor error response', error);
}
return createAnthropicErrorResponse(response.status, type, message, headers);
}
async function createAnthropicJsonResponse(response: Response): Promise<Response> {
try {
const openAiResponse = await response.json();
if (!hasTranslatableChoices(openAiResponse)) {
return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE);
}
const anthropicResponse = new GlmtTransformer().transformResponse(openAiResponse);
if (isSyntheticTransformationFallback(anthropicResponse)) {
logTranslationError(
'Cursor JSON translation produced synthetic fallback response',
anthropicResponse
);
return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE);
}
return new Response(JSON.stringify(anthropicResponse), {
status: response.status,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
logTranslationError('Cursor JSON translation failed', error);
return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE);
}
}
function createAnthropicStreamingResponse(response: Response): Response {
const body = response.body;
if (!body) {
return createAnthropicErrorResponse(
502,
'api_error',
'Cursor stream ended before a response body was available'
);
}
const parser = new SSEParser({ throwOnMalformedJson: true });
const transformer = new GlmtTransformer();
const accumulator = new DeltaAccumulator({});
const encoder = new TextEncoder();
const readable = new ReadableStream<Uint8Array>({
async start(controller) {
const reader = body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
if (!value) {
continue;
}
const events = parser.parse(Buffer.from(value));
events.forEach((event) => {
const anthropicEvents = transformer.transformDelta(event as SSEEvent, accumulator);
anthropicEvents.forEach((anthropicEvent) => {
controller.enqueue(
encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data))
);
});
});
}
if (!accumulator.isFinalized() && accumulator.isMessageStarted()) {
transformer.finalizeDelta(accumulator).forEach((anthropicEvent) => {
controller.enqueue(
encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data))
);
});
}
} catch (error) {
logTranslationError('Cursor SSE translation failed', error);
controller.enqueue(
encoder.encode(
formatSseEvent(
'error',
createAnthropicErrorPayload('api_error', STREAM_TRANSLATION_ERROR_MESSAGE)
)
)
);
} finally {
reader.releaseLock();
controller.close();
}
},
});
return new Response(readable, {
status: response.status,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
export async function createAnthropicProxyResponse(response: Response): Promise<Response> {
if (!response.ok) {
return createAnthropicErrorProxyResponse(response);
}
const contentType = (response.headers.get('content-type') || '').toLowerCase();
const isEventStream =
contentType === 'text/event-stream' || contentType.startsWith('text/event-stream;');
return isEventStream
? createAnthropicStreamingResponse(response)
: createAnthropicJsonResponse(response);
}
+216
View File
@@ -0,0 +1,216 @@
import type { CursorTool } from './cursor-protobuf-schema';
import type {
AnthropicContentBlock,
CursorAnthropicRequest,
CursorOpenAIMessage,
} from './cursor-anthropic-types';
export interface TranslatedAnthropicRequest {
model?: string;
stream: boolean;
reasoning_effort?: string;
tools?: CursorTool[];
messages: CursorOpenAIMessage[];
}
const TOOL_RESULT_SERIALIZATION_FALLBACK = '[unserializable content]';
const TOOL_USE_ARGUMENTS_FALLBACK = '{}';
function assertObject(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null) {
throw new Error(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function safeJsonStringify(value: unknown, fallback: string): string {
try {
const serialized = JSON.stringify(value);
return typeof serialized === 'string' ? serialized : fallback;
} catch {
return fallback;
}
}
function createFallbackToolId(messageIndex: number, blockIndex: number): string {
return `toolu_ccs_fallback_${messageIndex}_${blockIndex}`;
}
function flattenTextContent(content: unknown, label: string): string {
if (typeof content === 'string') {
return content;
}
if (!Array.isArray(content)) {
throw new Error(`${label} must be a string or content block array`);
}
return content
.map((block, index) => {
const parsed = assertObject(block, `${label}[${index}]`);
if (parsed.type !== 'text') {
throw new Error(`${label}[${index}].type "${String(parsed.type)}" is not supported`);
}
return typeof parsed.text === 'string' ? parsed.text : '';
})
.join('\n');
}
function toToolResultContent(content: unknown, label: string): string {
if (content === undefined) {
return '';
}
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return flattenTextContent(content, label);
}
return safeJsonStringify(content, TOOL_RESULT_SERIALIZATION_FALLBACK);
}
function mapThinkingToReasoningEffort(
thinking: CursorAnthropicRequest['thinking']
): string | undefined {
if (!thinking) {
return undefined;
}
if (thinking.type === 'disabled') {
return undefined;
}
if (thinking.type !== 'enabled') {
throw new Error('thinking.type must be "enabled" or "disabled"');
}
return typeof thinking.budget_tokens === 'number' && thinking.budget_tokens >= 8192
? 'high'
: 'medium';
}
export function translateAnthropicRequest(raw: unknown): TranslatedAnthropicRequest {
const request = assertObject(raw, 'request') as CursorAnthropicRequest;
const translatedMessages: CursorOpenAIMessage[] = [];
if (request.system !== undefined) {
translatedMessages.push({
role: 'system',
content: flattenTextContent(request.system, 'system'),
});
}
if (!Array.isArray(request.messages)) {
throw new Error('messages must be an array');
}
request.messages.forEach((message, messageIndex) => {
const role = message.role;
if (role !== 'user' && role !== 'assistant') {
throw new Error(`messages[${messageIndex}].role must be "user" or "assistant"`);
}
const content = message.content;
if (typeof content === 'string') {
translatedMessages.push({ role, content });
return;
}
if (!Array.isArray(content)) {
throw new Error(`messages[${messageIndex}].content must be a string or array`);
}
const textParts: string[] = [];
const toolCalls: NonNullable<CursorOpenAIMessage['tool_calls']> = [];
let sawToolResult = false;
content.forEach((block, blockIndex) => {
const parsed = assertObject(
block,
`messages[${messageIndex}].content[${blockIndex}]`
) as unknown as AnthropicContentBlock;
if (parsed.type === 'text') {
textParts.push(typeof parsed.text === 'string' ? parsed.text : '');
return;
}
if (parsed.type === 'tool_use') {
if (role !== 'assistant') {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}] tool_use requires assistant role`
);
}
toolCalls.push({
id:
typeof parsed.id === 'string' && parsed.id.length > 0
? parsed.id
: createFallbackToolId(messageIndex, blockIndex),
type: 'function',
function: {
name: typeof parsed.name === 'string' ? parsed.name : 'tool',
arguments: safeJsonStringify(parsed.input ?? {}, TOOL_USE_ARGUMENTS_FALLBACK),
},
});
return;
}
if (parsed.type === 'tool_result') {
if (role !== 'user') {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}] tool_result requires user role`
);
}
if (typeof parsed.tool_use_id !== 'string' || parsed.tool_use_id.trim().length === 0) {
throw new Error(
`messages[${messageIndex}].content[${blockIndex}].tool_use_id must be a non-empty string`
);
}
sawToolResult = true;
if (textParts.length > 0) {
translatedMessages.push({
role,
content: textParts.join('\n'),
});
textParts.length = 0;
}
translatedMessages.push({
role: 'tool',
tool_call_id: parsed.tool_use_id,
content: toToolResultContent(
parsed.content,
`messages[${messageIndex}].content[${blockIndex}].content`
),
});
return;
}
throw new Error(
`messages[${messageIndex}].content[${blockIndex}].type "${String((parsed as { type?: unknown }).type)}" is not supported`
);
});
if (role === 'assistant') {
translatedMessages.push({
role,
content: textParts.join('\n'),
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
});
return;
}
if (textParts.length > 0 || !sawToolResult) {
translatedMessages.push({
role,
content: textParts.join('\n'),
});
}
});
return {
model:
typeof request.model === 'string' && request.model.trim().length > 0
? request.model
: undefined,
stream: request.stream === true,
reasoning_effort: mapThinkingToReasoningEffort(request.thinking),
tools: Array.isArray(request.tools) ? request.tools : undefined,
messages: translatedMessages,
};
}
+48
View File
@@ -0,0 +1,48 @@
import type { CursorTool } from './cursor-protobuf-schema';
export interface CursorOpenAIMessage {
role: string;
content: string;
name?: string;
tool_call_id?: string;
tool_calls?: Array<{
id: string;
type: string;
function: { name: string; arguments: string };
}>;
}
export interface AnthropicTextBlock {
type: 'text';
text?: string;
}
export interface AnthropicToolUseBlock {
type: 'tool_use';
id?: string;
name?: string;
input?: Record<string, unknown>;
}
export interface AnthropicToolResultBlock {
type: 'tool_result';
tool_use_id?: string;
content?: unknown;
}
export type AnthropicContentBlock =
| AnthropicTextBlock
| AnthropicToolUseBlock
| AnthropicToolResultBlock;
export interface CursorAnthropicRequest {
model?: string;
messages?: Array<{ role?: string; content?: string | AnthropicContentBlock[] }>;
system?: string | AnthropicTextBlock[];
stream?: boolean;
tools?: CursorTool[];
thinking?: {
type?: string;
budget_tokens?: number;
};
}
+64 -25
View File
@@ -7,6 +7,11 @@
import * as http from 'http';
import { Readable } from 'stream';
import { CursorExecutor } from './cursor-executor';
import {
createAnthropicErrorResponse,
createAnthropicProxyResponse,
} from './cursor-anthropic-response';
import { translateAnthropicRequest } from './cursor-anthropic-translator';
import { checkAuthStatus } from './cursor-auth';
import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models';
import type { CursorTool } from './cursor-protobuf-schema';
@@ -190,10 +195,12 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
const executor = new CursorExecutor();
const server = http.createServer(async (req, res) => {
try {
const method = req.method || 'GET';
const requestUrl = req.url || '/';
const method = req.method || 'GET';
const requestUrl = req.url || '/';
const isOpenAiRoute = method === 'POST' && requestUrl === '/v1/chat/completions';
const isAnthropicRoute = method === 'POST' && requestUrl === '/v1/messages';
try {
if (method === 'GET' && requestUrl === '/health') {
writeJson(res, 200, { ok: true, service: 'cursor-daemon' });
return;
@@ -222,13 +229,17 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
return;
}
if (method !== 'POST' || requestUrl !== '/v1/chat/completions') {
if (!isOpenAiRoute && !isAnthropicRoute) {
writeJson(res, 404, { error: 'Not found' });
return;
}
const parsedBody = (await readJsonBody(req)) as OpenAIChatRequest;
const messages = normalizeMessages(parsedBody.messages);
const rawBody = await readJsonBody(req);
const anthropicBody = isAnthropicRoute ? translateAnthropicRequest(rawBody) : undefined;
const parsedBody = anthropicBody ?? ((rawBody as OpenAIChatRequest) || {});
const messages = anthropicBody
? anthropicBody.messages
: normalizeMessages(parsedBody.messages);
const requestedModel =
typeof parsedBody.model === 'string' && parsedBody.model.trim().length > 0
? parsedBody.model.trim()
@@ -237,22 +248,38 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
const authStatus = checkAuthStatus();
if (!authStatus.authenticated || !authStatus.credentials) {
writeJson(res, 401, {
error: {
type: 'authentication_error',
message: 'Cursor credentials not found. Run `ccs cursor auth` first.',
},
});
const message = 'Cursor credentials not found. Run `ccs cursor auth` first.';
if (isAnthropicRoute) {
await pipeWebResponseToNode(
createAnthropicErrorResponse(401, 'authentication_error', message),
res
);
} else {
writeJson(res, 401, {
error: {
type: 'authentication_error',
message,
},
});
}
return;
}
if (authStatus.expired) {
writeJson(res, 401, {
error: {
type: 'authentication_error',
message: 'Cursor credentials expired. Run `ccs cursor auth` again.',
},
});
const message = 'Cursor credentials expired. Run `ccs cursor auth` again.';
if (isAnthropicRoute) {
await pipeWebResponseToNode(
createAnthropicErrorResponse(401, 'authentication_error', message),
res
);
} else {
writeJson(res, 401, {
error: {
type: 'authentication_error',
message,
},
});
}
return;
}
@@ -301,16 +328,28 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
},
});
await pipeWebResponseToNode(result.response, res);
const outgoingResponse = isAnthropicRoute
? await createAnthropicProxyResponse(result.response)
: result.response;
await pipeWebResponseToNode(outgoingResponse, res);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
const isPayloadTooLarge = message.includes('Request body too large');
writeJson(res, isPayloadTooLarge ? 413 : 400, {
error: {
type: 'invalid_request_error',
message,
},
});
const status = isPayloadTooLarge ? 413 : 400;
if (isAnthropicRoute) {
await pipeWebResponseToNode(
createAnthropicErrorResponse(status, 'invalid_request_error', message),
res
);
} else {
writeJson(res, status, {
error: {
type: 'invalid_request_error',
message,
},
});
}
}
});
+48 -2
View File
@@ -281,6 +281,48 @@ function getCatalogDefaultModelId(availableModels: CursorModel[]): string {
return firstAvailable || DEFAULT_CURSOR_MODEL;
}
function addLookupCandidate(candidates: Set<string>, value: string): void {
const normalized = value.trim().toLowerCase();
if (normalized) {
candidates.add(normalized);
}
}
function buildCursorAnthropicModelLookupCandidates(requestedModel: string): string[] {
const candidates = new Set<string>();
const raw = requestedModel.trim().toLowerCase();
addLookupCandidate(candidates, raw);
let normalized = raw.replace(/^[a-z0-9_-]+\//, '');
addLookupCandidate(candidates, normalized);
while (true) {
const stripped = normalized
.replace(/\(\d+\)$/i, '')
.replace(/\[1m\]$/i, '')
.replace(/-thinking$/i, '')
.replace(/-\d{8}$/i, '');
if (stripped === normalized) {
break;
}
normalized = stripped;
addLookupCandidate(candidates, normalized);
}
const anthropicAliasMatch = normalized.match(
/^claude-(opus|sonnet|haiku)-(\d+)(?:[.-](\d+))?(?:-(1m|fast-mode))?$/i
);
if (anthropicAliasMatch) {
const [, family, major, minor, variant] = anthropicAliasMatch;
const cursorModelId = `claude-${major}${minor ? `.${minor}` : ''}-${family.toLowerCase()}${variant ? `-${variant.toLowerCase()}` : ''}`;
addLookupCandidate(candidates, cursorModelId);
}
return [...candidates];
}
export function resolveCursorRequestModel(
requestedModel: string | null | undefined,
availableModels: CursorModel[]
@@ -291,8 +333,12 @@ export function resolveCursorRequestModel(
return fallbackModel;
}
if (availableModels.some((model) => model.id === normalizedRequested)) {
return normalizedRequested;
const lookupCandidates = new Set(buildCursorAnthropicModelLookupCandidates(normalizedRequested));
const matchedModel = availableModels.find((model) =>
lookupCandidates.has(model.id.toLowerCase())
);
if (matchedModel) {
return matchedModel.id;
}
return fallbackModel;
+4 -3
View File
@@ -1,5 +1,5 @@
/**
* GlmtProxy - Embedded HTTP proxy for GLM thinking support
* GlmtProxy - Legacy embedded HTTP proxy retained for compatibility work.
*
* Architecture:
* - Intercepts Claude CLI Z.AI calls
@@ -7,8 +7,9 @@
* - Converts reasoning_content thinking blocks
* - Supports both streaming and buffered modes
*
* Lifecycle:
* - Spawned by bin/ccs.js when 'glmt' profile detected
* Current status:
* - No longer started by the normal `ccs glmt` runtime path
* - Kept for legacy/internal compatibility and transformer-adjacent tests
* - Binds to 127.0.0.1:random_port (security + avoid conflicts)
* - Terminates when parent process exits
*
+6
View File
@@ -19,6 +19,7 @@
interface SSEParserOptions {
maxBufferSize?: number;
throwOnMalformedJson?: boolean;
}
interface SSEEvent {
@@ -33,11 +34,13 @@ export class SSEParser {
private buffer: string;
private eventCount: number;
private maxBufferSize: number;
private throwOnMalformedJson: boolean;
constructor(options: SSEParserOptions = {}) {
this.buffer = '';
this.eventCount = 0;
this.maxBufferSize = options.maxBufferSize || 1024 * 1024; // 1MB default
this.throwOnMalformedJson = options.throwOnMalformedJson === true;
}
/**
@@ -92,6 +95,9 @@ export class SSEParser {
data.substring(0, 100)
);
}
if (this.throwOnMalformedJson) {
throw new Error(`Malformed SSE JSON event: ${(e as Error).message}`);
}
}
}
} else if (line.startsWith('id: ')) {
+2
View File
@@ -58,6 +58,8 @@ class InstanceManager {
await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy);
});
this.sharedManager.normalizeSharedPluginMetadataPaths(instancePath);
// Sync MCP servers from global ~/.claude.json (unless bare)
if (!options.bare) {
this.syncMcpServers(instancePath);
+2 -2
View File
@@ -200,8 +200,8 @@ class RecoveryManager {
* Run all recovery operations (lazy initialization)
* Mirrors postinstall.js behavior
*
* NOTE: GLM/GLMT/Kimi profiles are NOT auto-created.
* Users should create them via `ccs api create --preset glm` or the UI.
* NOTE: GLM/Kimi profiles are NOT auto-created.
* Users should create them via `ccs api create --preset glm|km` or the UI.
*/
recoverAll(): boolean {
this.recovered = [];
+117 -15
View File
@@ -18,6 +18,50 @@ interface SharedItem {
type: 'directory' | 'file';
}
export function normalizePluginMetadataPathString(input: string): string {
return input.replace(
/([\\/])\.ccs\1instances\1[^\\/]+\1/g,
(_match, separator: string) => `${separator}.claude${separator}`
);
}
function normalizePluginMetadataValue(value: unknown): { normalized: unknown; changed: boolean } {
if (typeof value === 'string') {
const normalized = normalizePluginMetadataPathString(value);
return { normalized, changed: normalized !== value };
}
if (Array.isArray(value)) {
let changed = false;
const normalized = value.map((item) => {
const result = normalizePluginMetadataValue(item);
changed = changed || result.changed;
return result.normalized;
});
return { normalized, changed };
}
if (value && typeof value === 'object') {
let changed = false;
const normalized = Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => {
const result = normalizePluginMetadataValue(item);
changed = changed || result.changed;
return [key, result.normalized];
})
);
return { normalized, changed };
}
return { normalized: value, changed: false };
}
export function normalizePluginMetadataContent(original: string): string {
const parsed = JSON.parse(original) as unknown;
const result = normalizePluginMetadataValue(parsed);
return result.changed ? JSON.stringify(result.normalized, null, 2) : original;
}
/**
* SharedManager Class
*/
@@ -210,8 +254,7 @@ class SharedManager {
}
}
// Normalize plugin registry paths after linking
this.normalizePluginRegistryPaths();
this.normalizeSharedPluginMetadataPaths(instancePath);
}
/**
@@ -539,6 +582,14 @@ class SharedManager {
}
}
/**
* Normalize shared plugin metadata files to canonical ~/.claude/ paths.
*/
normalizeSharedPluginMetadataPaths(configDir?: string): void {
this.normalizePluginRegistryPaths(configDir);
this.normalizeMarketplaceRegistryPaths(configDir);
}
/**
* Normalize plugin registry paths to use canonical ~/.claude/ paths
* instead of instance-specific ~/.ccs/instances/<name>/ paths.
@@ -546,31 +597,82 @@ class SharedManager {
* This ensures installed_plugins.json is consistent regardless of
* which CCS instance installed the plugin.
*/
normalizePluginRegistryPaths(): void {
const registryPath = path.join(this.claudeDir, 'plugins', 'installed_plugins.json');
normalizePluginRegistryPaths(configDir?: string): void {
this.normalizePluginMetadataFiles(
'installed_plugins.json',
configDir,
'Normalized plugin registry paths',
'plugin registry'
);
}
// Skip if registry doesn't exist
/**
* Normalize marketplace registry paths to use canonical ~/.claude/ paths
* instead of instance-specific ~/.ccs/instances/<name>/ paths.
*
* This ensures known_marketplaces.json is consistent regardless of
* which CCS instance added the marketplace.
*/
normalizeMarketplaceRegistryPaths(configDir?: string): void {
this.normalizePluginMetadataFiles(
'known_marketplaces.json',
configDir,
'Normalized marketplace registry paths',
'marketplace registry'
);
}
private normalizePluginMetadataFiles(
fileName: string,
configDir: string | undefined,
successMessage: string,
warningLabel: string
): void {
const seen = new Set<string>();
for (const registryPath of this.getPluginMetadataFilePaths(fileName, configDir)) {
const dedupeKey = this.resolveCanonicalPath(registryPath);
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
this.normalizePluginMetadataFile(registryPath, successMessage, warningLabel);
}
}
private getPluginMetadataFilePaths(fileName: string, configDir?: string): string[] {
const pluginDirs = new Set<string>([
path.join(this.claudeDir, 'plugins'),
path.join(this.sharedDir, 'plugins'),
]);
if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) {
pluginDirs.add(path.join(configDir, 'plugins'));
}
return [...pluginDirs].map((pluginDir) => path.join(pluginDir, fileName));
}
private normalizePluginMetadataFile(
registryPath: string,
successMessage: string,
warningLabel: string
): void {
if (!fs.existsSync(registryPath)) {
return;
}
try {
const original = fs.readFileSync(registryPath, 'utf8');
const normalized = normalizePluginMetadataContent(original);
// Replace instance paths with canonical claude path
// Pattern: /.ccs/instances/<instance-name>/ -> /.claude/
const normalized = original.replace(/\/\.ccs\/instances\/[^/]+\//g, '/.claude/');
// Only write if changes were made
if (normalized !== original) {
// Validate JSON before writing
JSON.parse(normalized);
fs.writeFileSync(registryPath, normalized, 'utf8');
console.log(ok('Normalized plugin registry paths'));
console.log(ok(successMessage));
}
} catch (err) {
// Log warning but don't fail - registry may be malformed
console.log(warn(`Could not normalize plugin registry: ${(err as Error).message}`));
console.log(warn(`Could not normalize ${warningLabel}: ${(err as Error).message}`));
}
}
+20 -2
View File
@@ -12,8 +12,10 @@ import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import { getProxyTarget } from '../cliproxy/proxy-target-resolver';
import { generateCopilotEnv } from '../copilot/copilot-executor';
import InstanceManager from '../management/instance-manager';
import SharedManager from '../management/shared-manager';
import { expandPath } from '../utils/helpers';
import { getClaudeSettingsPath } from '../utils/claude-config-path';
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from '../utils/glmt-deprecation';
import {
type ClaudeExtensionHost,
type ClaudeExtensionHostDefinition,
@@ -86,7 +88,12 @@ function describeProfile(profileName: string, result: ProfileDetectionResult): s
}
if (result.type === 'cliproxy')
return 'OAuth or CLIProxy-backed profile for Anthropic-compatible routing.';
if (result.type === 'settings') return 'API profile backed by a CCS settings file.';
if (result.type === 'settings') {
if (isDeprecatedGlmtProfileName(profileName)) {
return 'Deprecated GLMT compatibility profile normalized to the direct GLM API.';
}
return 'API profile backed by a CCS settings file.';
}
if (result.type === 'account')
return 'Claude account instance isolated through CLAUDE_CONFIG_DIR.';
if (result.type === 'copilot') return 'GitHub Copilot profile routed through copilot-api.';
@@ -161,6 +168,7 @@ async function resolveExtensionEnv(
profileType: result.type,
target: 'claude',
});
new SharedManager().normalizeSharedPluginMetadataPaths(continuity.claudeConfigDir);
if (continuity.claudeConfigDir) {
notes.push(`Default profile inherits continuity from account "${continuity.sourceAccount}".`);
return {
@@ -182,7 +190,7 @@ async function resolveExtensionEnv(
profileType: result.type,
target: 'claude',
});
const env =
let env =
result.type === 'settings'
? (result.env ??
(result.settingsPath ? loadSettingsFromFile(expandPath(result.settingsPath)) : {}))
@@ -228,12 +236,22 @@ async function resolveExtensionEnv(
: getEffectiveEnvVars(result.provider, port, result.settingsPath);
})();
if (result.type === 'settings' && isDeprecatedGlmtProfileName(requestedProfile)) {
const normalized = normalizeDeprecatedGlmtEnv(sortEnvRecord(env));
env = normalized.env;
warnings.push(...normalized.warnings);
notes.push('Create or migrate to a glm profile when convenient to remove legacy GLMT config.');
}
if (!requestedIsDefault && continuity.claudeConfigDir && !env.CLAUDE_CONFIG_DIR) {
env.CLAUDE_CONFIG_DIR = continuity.claudeConfigDir;
notes.push(
`Continuity inheritance adds CLAUDE_CONFIG_DIR from account "${continuity.sourceAccount}".`
);
}
new SharedManager().normalizeSharedPluginMetadataPaths(env.CLAUDE_CONFIG_DIR);
if (result.type === 'copilot') {
warnings.push(
'copilot-api must stay reachable for this profile to work inside the IDE extension.'
+34 -40
View File
@@ -8,19 +8,19 @@
export type PresetCategory = 'recommended' | 'alternative';
export const PROVIDER_PRESET_IDS = [
'anthropic',
'openrouter',
'alibaba-coding-plan',
'ollama',
'llamacpp',
'anthropic',
'glm',
'glmt',
'km',
'foundry',
'mm',
'deepseek',
'qwen',
'ollama-cloud',
'novita',
] as const;
export type ProviderPresetId = (typeof PROVIDER_PRESET_IDS)[number];
@@ -53,26 +53,13 @@ export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api';
* Keep this minimal and explicit to avoid hidden implicit behavior.
*/
export const PROVIDER_PRESET_ALIASES: Readonly<Record<string, ProviderPresetId>> = Object.freeze({
glmt: 'glm',
kimi: 'km',
alibaba: 'alibaba-coding-plan',
acp: 'alibaba-coding-plan',
});
const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [
{
id: 'anthropic',
name: 'Anthropic (Direct API)',
description: 'Use your own Anthropic API key (sk-ant-...)',
baseUrl: '',
defaultProfileName: 'anthropic',
defaultModel: 'claude-sonnet-4-5-20250929',
apiKeyPlaceholder: 'sk-ant-api03-...',
apiKeyHint: 'Get key at console.anthropic.com/settings/keys',
category: 'recommended',
requiresApiKey: true,
badge: 'Direct',
featured: true,
},
{
id: 'openrouter',
name: 'OpenRouter',
@@ -131,11 +118,27 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [
requiresApiKey: false,
badge: 'Local',
featured: true,
icon: '/assets/providers/llama-cpp.svg',
},
{
id: 'anthropic',
name: 'Anthropic (Direct API)',
description: 'Use your own Anthropic API key (sk-ant-...)',
baseUrl: '',
defaultProfileName: 'anthropic',
defaultModel: 'claude-sonnet-4-5-20250929',
apiKeyPlaceholder: 'sk-ant-api03-...',
apiKeyHint: 'Get key at console.anthropic.com/settings/keys',
category: 'recommended',
requiresApiKey: true,
badge: 'Direct',
featured: true,
icon: '/assets/providers/claude.svg',
},
{
id: 'glm',
name: 'GLM',
description: 'Claude via Z.AI',
description: 'Direct Z.AI Anthropic-compatible API profile',
baseUrl: 'https://api.z.ai/api/anthropic',
defaultProfileName: 'glm',
defaultModel: 'glm-5',
@@ -146,29 +149,6 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [
badge: 'Z.AI',
icon: '/icons/zai.svg',
},
{
id: 'glmt',
name: 'GLMT',
description: 'GLM with Thinking mode support',
baseUrl: 'https://api.z.ai/api/coding/paas/v4/chat/completions',
defaultProfileName: 'glmt',
defaultModel: 'glm-5',
apiKeyPlaceholder: 'ghp_...',
apiKeyHint: 'Same API key as GLM',
category: 'alternative',
requiresApiKey: true,
extraEnv: {
ANTHROPIC_TEMPERATURE: '0.2',
ANTHROPIC_MAX_TOKENS: '65536',
MAX_THINKING_TOKENS: '32768',
ENABLE_STREAMING: 'true',
ANTHROPIC_SAFE_MODE: 'false',
API_TIMEOUT_MS: '3000000',
},
alwaysThinkingEnabled: true,
badge: 'Thinking',
icon: '/icons/zai.svg',
},
{
id: 'km',
name: 'Kimi',
@@ -254,6 +234,20 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [
badge: 'Cloud',
icon: '/icons/ollama.svg',
},
{
id: 'novita',
name: 'Novita AI',
description: 'Anthropic-compatible API for Claude Code and CCS profiles',
baseUrl: 'https://api.novita.ai/anthropic',
defaultProfileName: 'novita',
defaultModel: 'deepseek/deepseek-v3.2',
apiKeyPlaceholder: 'YOUR_NOVITA_API_KEY',
apiKeyHint: 'Get your API key at novita.ai',
category: 'alternative',
requiresApiKey: true,
badge: 'Anthropic-compatible',
icon: '/icons/novita.svg',
},
];
function clonePresetDefinition(preset: ProviderPresetDefinition): ProviderPresetDefinition {
+1 -1
View File
@@ -32,7 +32,7 @@ export type {
DelegationEvent,
} from './delegation';
// GLMT types
// Legacy GLMT transformer types
export type {
AnthropicMessage,
ContentBlock,
+1 -1
View File
@@ -337,7 +337,7 @@ export function getSettingsPath(profile: string): string {
/**
* Get display name for a profile by reading ANTHROPIC_MODEL from settings
* @param profile - Profile name (glm, glmt, kimi, custom, etc.)
* @param profile - Profile name (glm, km, glmt compatibility, custom, etc.)
* @returns Formatted display name (e.g., 'GLM-4.7', 'Kimi', 'Custom-Model')
*/
export function getModelDisplayName(profile: string): string {
+3 -1
View File
@@ -9,6 +9,8 @@ import { Agent, Dispatcher, ProxyAgent, fetch as undiciFetch, setGlobalDispatche
import { getProxyResolution, shouldBypassProxy } from './proxy-env';
const FETCH_PROXY_PROTOCOLS = ['http:', 'https:'];
type RoutingDispatchOptions = Parameters<Dispatcher['dispatch']>[0];
type RoutingDispatchHandler = Parameters<Dispatcher['dispatch']>[1];
type GlobalFetchProxyConfig = {
httpProxyUrl?: string;
@@ -27,7 +29,7 @@ class RoutingProxyDispatcher extends Dispatcher {
this.httpsProxyDispatcher = httpsProxyUrl ? new ProxyAgent(httpsProxyUrl) : null;
}
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers): boolean {
dispatch(options: RoutingDispatchOptions, handler: RoutingDispatchHandler): boolean {
return this.resolveDispatcher(options.origin).dispatch(options, handler);
}
+81
View File
@@ -0,0 +1,81 @@
const LEGACY_GLMT_PROFILE = 'glmt';
const LEGACY_GLMT_BASE_URL = 'https://api.z.ai/api/coding/paas/v4/chat/completions';
const DIRECT_GLM_BASE_URL = 'https://api.z.ai/api/anthropic';
const GLMT_PROXY_ONLY_ENV_KEYS = [
'API_TIMEOUT_MS',
'ANTHROPIC_SAFE_MODE',
'ENABLE_STREAMING',
'MAX_THINKING_TOKENS',
] as const;
export interface GlmtNormalizationResult {
env: Record<string, string>;
warnings: string[];
migrated: boolean;
}
export function isDeprecatedGlmtProfileName(profileName: string | null | undefined): boolean {
return (profileName || '').trim().toLowerCase() === LEGACY_GLMT_PROFILE;
}
export function isLegacyGlmtBaseUrl(baseUrl: string | null | undefined): boolean {
const normalized = (baseUrl || '').trim().toLowerCase().replace(/\/+$/, '');
if (!normalized) {
return false;
}
return (
normalized === LEGACY_GLMT_BASE_URL ||
normalized.includes('/api/coding/paas/v4') ||
normalized.endsWith('/chat/completions')
);
}
export function normalizeDeprecatedGlmtEnv(env: Record<string, string>): GlmtNormalizationResult {
const normalizedEnv = { ...env };
let migrated = false;
if (
!normalizedEnv['ANTHROPIC_BASE_URL'] ||
isLegacyGlmtBaseUrl(normalizedEnv['ANTHROPIC_BASE_URL'])
) {
normalizedEnv['ANTHROPIC_BASE_URL'] = DIRECT_GLM_BASE_URL;
migrated = true;
}
for (const key of GLMT_PROXY_ONLY_ENV_KEYS) {
if (key in normalizedEnv) {
delete normalizedEnv[key];
migrated = true;
}
}
return {
env: normalizedEnv,
warnings: buildGlmtCompatibilityWarnings(migrated),
migrated,
};
}
export function buildGlmtCompatibilityWarnings(migrated: boolean): string[] {
const warnings = [
'GLMT is deprecated and kept only as a compatibility path.',
'Use ccs glm for Z.AI API profiles.',
'Use ccs km for reasoning-first Kimi API profiles.',
];
if (migrated) {
warnings.splice(
1,
0,
'CCS normalized legacy GLMT proxy settings to the direct GLM endpoint for this run.'
);
}
return warnings;
}
export function getDirectGlmBaseUrl(): string {
return DIRECT_GLM_BASE_URL;
}
+9
View File
@@ -9,6 +9,7 @@ import { ErrorManager } from './error-manager';
import { getWebSearchHookEnv } from './websearch-manager';
import { wireChildProcessSignals } from './signal-forwarder';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
import SharedManager from '../management/shared-manager';
/**
* Strip ANTHROPIC_* env vars from an environment object.
@@ -122,6 +123,14 @@ export function execClaude(
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
const env = stripClaudeCodeEnv(mergedEnv);
if (profileType !== 'account') {
try {
new SharedManager().normalizeSharedPluginMetadataPaths(env.CLAUDE_CONFIG_DIR);
} catch {
// Best-effort normalization should never block Claude launch.
}
}
// propagate key env vars to tmux session so agent team teammates
// (spawned via tmux split-window) inherit the correct config dir
if (process.env.TMUX && envVars) {
+49 -3
View File
@@ -17,6 +17,7 @@ import { shutdownUsageAggregator } from './usage/aggregator';
export interface ServerOptions {
port: number;
host?: string;
staticDir?: string;
dev?: boolean;
}
@@ -112,11 +113,56 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
};
// Start listening
return new Promise<ServerInstance>((resolve) => {
server.listen(options.port, () => {
return new Promise<ServerInstance>((resolve, reject) => {
const onError = (error: NodeJS.ErrnoException) => {
cleanup();
reject(new Error(formatListenError(error, options)));
};
server.once('error', onError);
const onListening = () => {
server.off('error', onError);
// Usage cache loads on-demand when Analytics page is visited
// This keeps server startup instant for users who don't need analytics
resolve({ server, wss, cleanup });
});
};
try {
if (options.host) {
server.listen(options.port, options.host, onListening);
return;
}
server.listen(options.port, onListening);
} catch (error) {
server.off('error', onError);
cleanup();
reject(new Error(formatListenError(error as NodeJS.ErrnoException, options)));
}
});
}
function formatListenError(error: NodeJS.ErrnoException, options: ServerOptions): string {
if (error.code === 'EADDRINUSE' && options.host) {
return `Unable to bind ${options.host}:${options.port}; the address may be unavailable or the port may already be in use`;
}
if (error.code === 'EADDRINUSE') {
return `Port ${options.port} is already in use`;
}
if (error.code === 'EADDRNOTAVAIL' && options.host) {
return `Cannot bind to ${options.host}:${options.port} on this machine`;
}
if (error.code === 'EACCES') {
return `Permission denied while binding to port ${options.port}`;
}
if (options.host) {
return `Cannot bind to ${options.host}:${options.port}: ${error.message}`;
}
return error.message;
}
+1
View File
@@ -458,3 +458,4 @@ router.delete('/:name', (req: Request, res: Response): void => {
});
export default router;
export { parseTarget } from './route-helpers';
+1
View File
@@ -295,3 +295,4 @@ router.delete('/:name', (req: Request, res: Response): void => {
});
export default router;
export { parseTarget } from './route-helpers';
+15 -13
View File
@@ -5,13 +5,13 @@
```
tests/
├── unit/ # Module unit tests (Mocha)
│ ├── glmt/ # GLMT transformer tests
│ ├── glmt/ # Legacy GLMT transformer/internal compatibility tests
│ └── delegation/ # Delegation module tests
├── npm/ # npm package tests (Mocha)
├── native/ # Native installation tests (bash/PowerShell)
│ ├── unix/ # Unix/Linux/macOS tests
│ └── windows/ # Windows PowerShell tests
├── integration/ # Integration tests (manual execution)
├── integration/ # Integration + smoke tests
└── shared/ # Shared utilities
├── fixtures/ # Test configuration and environment
├── unit/ # Helper function tests
@@ -22,9 +22,9 @@ tests/
## Running Tests
```bash
bun run test # All automated tests (unit + npm)
bun run test:unit # Unit tests only (Mocha)
bun run test:npm # npm package tests (Mocha)
bun run test # All automated tests (unit + integration + npm)
bun run test:unit # Unit tests only
bun run test:npm # npm package tests
bun run test:native # Native Unix tests (bash)
```
@@ -32,7 +32,7 @@ bun run test:native # Native Unix tests (bash)
### Unit Tests (`unit/`)
Module-level tests using Mocha framework:
- `unit/glmt/` - GLMT transformer, SSE parser, delta accumulator
- `unit/glmt/` - Legacy transformer internals kept for Cursor translation compatibility
- `unit/delegation/` - Permission mode, session manager, result formatter
### npm Tests (`npm/`)
@@ -48,16 +48,18 @@ Installation tests for curl|bash (Unix) and irm|iex (Windows):
- `native/windows/edge-cases.ps1` - Windows edge case tests
### Integration Tests (`integration/`)
Manual execution tests for specific scenarios:
- `token-counting-test.js` - Token counting validation
- `z-ai-streaming-test.js` - Z.AI streaming
- `glmt-integration-test.sh` - GLMT integration
Integration and smoke coverage for scenarios that exercise multiple layers:
- Automated `*.test.ts` files run as part of `bun run test:all` and CI
- Shell and standalone probe scripts remain on-demand for targeted debugging
- `cursor-daemon-lifecycle.test.ts` - local daemon process + HTTP smoke coverage
- `image-analyzer-hook.test.ts` - hook integration coverage
- `glmt-integration-test.sh` - legacy GLMT compatibility smoke probe
- `symlink-chain-test.sh` - Symlink chain handling
- `ux-integration-test.sh` - CLI UX integration
## Adding New Tests
- **Unit tests**: Add to `unit/<module>/` using Mocha + Node.js assert
- **npm tests**: Add to `npm/` using Mocha
- **Unit tests**: Add to `unit/<module>/` for isolated module behavior
- **npm tests**: Add to `npm/` for package behavior
- **Native tests**: Add to `native/unix/` or `native/windows/`
- **Integration tests**: Add to `integration/`
- **Integration tests**: Add automated cross-layer smoke coverage to `integration/*.test.ts`
@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { isDaemonRunning, startDaemon, stopDaemon } from '../../src/cursor/cursor-daemon';
import { saveCredentials } from '../../src/cursor/cursor-auth';
let originalCcsHome: string | undefined;
let tempDir: string;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-daemon-integration-'));
process.env.CCS_HOME = tempDir;
});
afterEach(async () => {
await stopDaemon();
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('cursor daemon lifecycle smoke', () => {
it('starts, serves expected routes, and stops cleanly', async () => {
const port = 10000 + Math.floor(Math.random() * 50000);
const result = await startDaemon({ port, ghost_mode: true });
expect(result.success).toBe(true);
expect(result.pid).toBeDefined();
expect(await isDaemonRunning(port)).toBe(true);
const modelsResponse = await fetch(`http://127.0.0.1:${port}/v1/models`);
expect(modelsResponse.status).toBe(200);
const modelsJson = (await modelsResponse.json()) as { object?: string; data?: unknown[] };
expect(modelsJson.object).toBe('list');
expect(Array.isArray(modelsJson.data)).toBe(true);
const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4.1',
messages: [{ role: 'user', content: 'hello' }],
}),
});
expect(chatResponse.status).toBe(401);
const anthropicResponse = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-sonnet-4.5',
max_tokens: 256,
messages: [{ role: 'user', content: 'hello' }],
}),
});
expect(anthropicResponse.status).toBe(401);
const anthropicBody = (await anthropicResponse.json()) as {
type?: string;
error?: { type?: string; message?: string };
};
expect(anthropicBody.type).toBe('error');
expect(anthropicBody.error?.type).toBe('authentication_error');
expect(anthropicBody.error?.message).toContain('Run `ccs cursor auth` first');
const stopResult = await stopDaemon();
expect(stopResult.success).toBe(true);
expect(await isDaemonRunning(port)).toBe(false);
}, 35000);
it('returns 404 for unknown routes', async () => {
const port = 10000 + Math.floor(Math.random() * 50000);
const result = await startDaemon({ port, ghost_mode: true });
expect(result.success).toBe(true);
const response = await fetch(`http://127.0.0.1:${port}/unknown`);
expect(response.status).toBe(404);
});
it('returns 401 when credentials are expired', async () => {
const port = 10000 + Math.floor(Math.random() * 50000);
const expiredAt = new Date(Date.now() - 26 * 60 * 60 * 1000).toISOString();
saveCredentials({
accessToken: 'a'.repeat(60),
machineId: '1234567890abcdef1234567890abcdef',
authMethod: 'manual',
importedAt: expiredAt,
});
const result = await startDaemon({ port, ghost_mode: true });
expect(result.success).toBe(true);
const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4.1',
messages: [{ role: 'user', content: 'hello' }],
}),
});
expect(response.status).toBe(401);
const body = (await response.json()) as { error?: { message?: string } };
expect(body.error?.message).toContain('expired');
});
it('validates invalid JSON, invalid message schema, and oversized body', async () => {
const port = 10000 + Math.floor(Math.random() * 50000);
const result = await startDaemon({ port, ghost_mode: true });
expect(result.success).toBe(true);
const invalidJson = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{invalid-json',
});
expect(invalidJson.status).toBe(400);
const invalidSchema = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4.1',
messages: { role: 'user', content: 'hello' },
}),
});
expect(invalidSchema.status).toBe(400);
const invalidAnthropic = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-sonnet-4.5',
max_tokens: 256,
messages: [{ role: 'user', content: [{ type: 'image' }] }],
}),
});
expect(invalidAnthropic.status).toBe(400);
const invalidAnthropicBody = (await invalidAnthropic.json()) as {
type?: string;
error?: { type?: string; message?: string };
};
expect(invalidAnthropicBody.type).toBe('error');
expect(invalidAnthropicBody.error?.type).toBe('invalid_request_error');
expect(invalidAnthropicBody.error?.message).toContain('is not supported');
const oversized = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4.1',
messages: [
{
role: 'user',
content: 'x'.repeat(10 * 1024 * 1024 + 1024),
},
],
}),
});
expect(oversized.status).toBe(413);
});
});
+53 -139
View File
@@ -1,170 +1,84 @@
#!/usr/bin/env bash
#
# GLMT Integration Test Suite
# Tests proxy startup, configuration, and basic functionality
# Legacy GLMT compatibility smoke probe
# Verifies that user-facing GLMT marketing is gone while internal transformer
# modules still exist for compatibility and Cursor translation.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CCS_DIR="$(dirname "$SCRIPT_DIR")"
echo "=== GLMT Integration Test Suite ==="
echo ""
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
REPO_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
PASSED=0
FAILED=0
test_pass() {
echo -e "${GREEN}${NC} $1"
((PASSED++))
echo "[OK] $1"
PASSED=$((PASSED + 1))
}
test_fail() {
echo -e "${RED}${NC} $1"
((FAILED++))
echo "[X] $1"
FAILED=$((FAILED + 1))
}
test_info() {
echo -e "${YELLOW}${NC} $1"
echo "[i] $1"
}
# Test 1: Check GLMT profile exists in config
echo "Test 1: GLMT profile configuration"
if grep -q '"glmt"' ~/.ccs/config.json; then
test_pass "GLMT profile found in config.json"
else
test_fail "GLMT profile NOT found in config.json"
fi
echo "=== Legacy GLMT Compatibility Smoke Test ==="
echo
# Test 2: Check GLMT settings file exists
echo "Test 2: GLMT settings file"
if [ -f ~/.ccs/glmt.settings.json ]; then
test_pass "GLMT settings file exists"
# Check if API key is configured
if grep -q "YOUR_GLM_API_KEY_HERE" ~/.ccs/glmt.settings.json; then
test_info "API key not configured (still has placeholder)"
else
test_pass "API key configured"
fi
else
test_fail "GLMT settings file NOT found"
fi
# Test 3: Check transformer module
echo "Test 3: Transformer module"
if [ -f "$CCS_DIR/bin/glmt-transformer.js" ]; then
test_pass "Transformer module exists"
# Test syntax
if node --check "$CCS_DIR/bin/glmt-transformer.js" 2>/dev/null; then
test_pass "Transformer syntax valid"
else
test_fail "Transformer syntax invalid"
fi
else
test_fail "Transformer module NOT found"
fi
# Test 4: Check proxy module
echo "Test 4: Proxy module"
if [ -f "$CCS_DIR/bin/glmt-proxy.js" ]; then
test_pass "Proxy module exists"
# Test syntax
if node --check "$CCS_DIR/bin/glmt-proxy.js" 2>/dev/null; then
test_pass "Proxy syntax valid"
else
test_fail "Proxy syntax invalid"
fi
else
test_fail "Proxy module NOT found"
fi
# Test 5: Test proxy startup
echo "Test 5: Proxy startup test"
test_info "Starting proxy in background..."
# Start proxy
node "$CCS_DIR/bin/glmt-proxy.js" &
PROXY_PID=$!
# Wait for PROXY_READY signal (with timeout)
TIMEOUT=5
PORT=""
for i in $(seq 1 $TIMEOUT); do
if ps -p $PROXY_PID > /dev/null 2>&1; then
sleep 0.2
# Check if proxy outputted anything (we can't easily capture it in background)
if [ $i -eq $TIMEOUT ]; then
test_fail "Proxy started but PROXY_READY signal not captured (this is OK - proxy works)"
PORT="unknown"
fi
else
test_fail "Proxy failed to start or exited immediately"
break
fi
echo "Test 1: Internal transformer sources remain available"
for file in \
"$REPO_DIR/src/glmt/glmt-transformer.ts" \
"$REPO_DIR/src/glmt/delta-accumulator.ts" \
"$REPO_DIR/src/glmt/sse-parser.ts" \
"$REPO_DIR/src/cursor/cursor-anthropic-response.ts"; do
if [ -f "$file" ]; then
test_pass "$(basename "$file") exists"
else
test_fail "Missing internal compatibility file: $file"
fi
done
if ps -p $PROXY_PID > /dev/null 2>&1; then
test_pass "Proxy process is running (PID: $PROXY_PID)"
# Kill proxy
kill $PROXY_PID 2>/dev/null || true
sleep 0.5
if ! ps -p $PROXY_PID > /dev/null 2>&1; then
test_pass "Proxy terminated gracefully"
else
test_fail "Proxy did not terminate (killing forcefully)"
kill -9 $PROXY_PID 2>/dev/null || true
fi
fi
# Test 6: Unit tests
echo "Test 6: Unit tests"
if [ -f "$CCS_DIR/tests/glmt-transformer.test.js" ]; then
test_info "Running transformer unit tests..."
if node "$CCS_DIR/tests/glmt-transformer.test.js" | grep -q "Passed: 12/12"; then
test_pass "All 12 unit tests passed"
else
test_fail "Some unit tests failed"
fi
echo
echo "Test 2: Root help no longer advertises ccs glmt"
if command -v ccs >/dev/null 2>&1; then
if ccs --help | grep -q "ccs glmt"; then
test_fail "Root help still advertises ccs glmt"
else
test_pass "Root help hides ccs glmt"
fi
else
test_fail "Unit test file NOT found"
test_info "ccs binary not found in PATH; skipping live help probe"
fi
# Test 7: Help text
echo "Test 7: Help text verification"
if ccs --help | grep -q "ccs glmt"; then
test_pass "GLMT appears in help text"
echo
echo "Test 3: Legacy glmt settings file inspection"
if [ -f "$HOME/.ccs/glmt.settings.json" ]; then
test_pass "Legacy glmt.settings.json detected"
if grep -q "api/coding/paas/v4/chat/completions" "$HOME/.ccs/glmt.settings.json"; then
test_info "Legacy proxy endpoint still present in settings; CCS should normalize it at runtime"
else
test_info "Settings already point at a direct endpoint or custom override"
fi
else
test_fail "GLMT NOT in help text"
test_info "No legacy glmt.settings.json found; nothing to migrate locally"
fi
# Summary
echo ""
echo "=== Test Summary ==="
echo -e "Passed: ${GREEN}$PASSED${NC}"
echo -e "Failed: ${RED}$FAILED${NC}"
echo ""
echo
echo "=== Summary ==="
echo "Passed: $PASSED"
echo "Failed: $FAILED"
echo
if [ $FAILED -eq 0 ]; then
echo -e "${GREEN}All tests passed!${NC}"
echo ""
echo "Next steps:"
echo " 1. Test with Claude Code: ccs glmt \"What is 2+2?\""
echo " 2. Test thinking tags: ccs glmt \"<Thinking:On> Explain recursion\""
echo " 3. Check thinking blocks appear in Claude Code UI"
exit 0
else
echo -e "${RED}Some tests failed. Please review above.${NC}"
exit 1
if [ "$FAILED" -eq 0 ]; then
echo "[OK] Legacy compatibility surface looks consistent."
echo "[i] Preferred profiles: ccs glm for Z.AI, ccs km for reasoning-first Kimi."
exit 0
fi
echo "[X] Review the failures above."
exit 1
+3 -2
View File
@@ -77,8 +77,9 @@ describe('npm CLI', () => {
});
describe('Profile handling', () => {
// Note: GLM/GLMT/Kimi profiles are no longer auto-created (v6.0)
// Users create these via UI presets or CLI: ccs api create --preset glm
// Note: GLM/Kimi profiles are no longer auto-created (v6.0).
// Legacy GLMT files may still exist, but new supported API profiles are created
// via UI presets or CLI: ccs api create --preset glm
it('shows helpful error for non-existent profile', function() {
try {
+4 -3
View File
@@ -46,8 +46,9 @@ describe('npm postinstall', () => {
env: { ...process.env, CCS_HOME: testEnv.testHome }
});
// GLM/GLMT/Kimi profiles are NO LONGER auto-created during install
// Users create these via UI presets or CLI: ccs api create --preset glm
// GLM/Kimi profiles are NO LONGER auto-created during install.
// Legacy glmt.settings.json files may still exist from older setups.
// Users create supported API profiles via UI presets or CLI: ccs api create --preset glm
assert(!testEnv.fileExists('glm.settings.json'), 'glm.settings.json should NOT be auto-created');
assert(!testEnv.fileExists('glmt.settings.json'), 'glmt.settings.json should NOT be auto-created');
assert(!testEnv.fileExists('kimi.settings.json'), 'kimi.settings.json should NOT be auto-created');
@@ -110,7 +111,7 @@ describe('npm postinstall', () => {
// Verify existing file still exists and new files are created
assert(testEnv.fileExists('existing.txt'), 'Existing files should be preserved');
assert(testEnv.fileExists('config.yaml'), 'config.yaml should be created');
// GLM/GLMT/Kimi are no longer auto-created
// GLM/Kimi are no longer auto-created. Legacy GLMT files remain untouched if present.
assert(!testEnv.fileExists('glm.settings.json'), 'glm.settings.json should NOT be auto-created');
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'bun:test';
import { getPresetById, isValidPresetId } from '../../../src/api/services/provider-presets';
describe('provider-presets-novita', () => {
it('resolves novita preset id', () => {
const preset = getPresetById('novita');
expect(preset?.id).toBe('novita');
expect(preset?.baseUrl).toBe('https://api.novita.ai/anthropic');
expect(preset?.defaultProfileName).toBe('novita');
});
it('resolves novita preset with expected model IDs', () => {
const preset = getPresetById('novita');
expect(preset?.defaultModel).toBe('deepseek/deepseek-v3.2');
});
it('validates novita preset requires API key', () => {
const preset = getPresetById('novita');
expect(preset?.requiresApiKey).toBe(true);
});
it('treats novita as a valid preset id', () => {
expect(isValidPresetId('novita')).toBe(true);
});
it('handles whitespace in novita preset id', () => {
const preset = getPresetById(' novita ');
expect(preset?.id).toBe('novita');
});
it('handles uppercase novita preset id', () => {
const preset = getPresetById('NOVITA');
expect(preset?.id).toBe('novita');
});
it('does not resolve partial or invalid novita ids', () => {
expect(getPresetById('novita-invalid')).toBeUndefined();
expect(isValidPresetId('novita-invalid')).toBe(false);
});
});
+37 -1
View File
@@ -1,5 +1,11 @@
import { existsSync } from 'fs';
import { resolve } from 'path';
import { describe, expect, it } from 'bun:test';
import { getPresetById, isValidPresetId } from '../../../src/api/services/provider-presets';
import {
PROVIDER_PRESETS,
getPresetById,
isValidPresetId,
} from '../../../src/api/services/provider-presets';
describe('provider-presets', () => {
it('resolves Alibaba Coding Plan preset id', () => {
@@ -29,6 +35,7 @@ describe('provider-presets', () => {
expect(preset?.requiresApiKey).toBe(false);
expect(preset?.apiKeyPlaceholder).toBe('llamacpp');
expect(preset?.baseUrl).toBe('http://127.0.0.1:8080');
expect(preset?.icon).toBe('/assets/providers/llama-cpp.svg');
});
it('resolves legacy kimi preset alias to km', () => {
@@ -36,6 +43,12 @@ describe('provider-presets', () => {
expect(preset?.id).toBe('km');
});
it('resolves legacy glmt preset alias to glm', () => {
const preset = getPresetById('glmt');
expect(preset?.id).toBe('glm');
expect(preset?.baseUrl).toBe('https://api.z.ai/api/anthropic');
});
it('resolves preset id with extra whitespace', () => {
const preset = getPresetById(' km ');
expect(preset?.id).toBe('km');
@@ -50,8 +63,31 @@ describe('provider-presets', () => {
expect(isValidPresetId('kimi')).toBe(true);
});
it('keeps glmt out of the canonical preset catalog while preserving alias compatibility', () => {
expect(PROVIDER_PRESETS.some((preset) => preset.id === 'glmt')).toBe(false);
expect(isValidPresetId('glmt')).toBe(true);
});
it('uses non-reserved default profile name for qwen API preset', () => {
const preset = getPresetById('qwen');
expect(preset?.defaultProfileName).toBe('qwen-api');
});
it('keeps Anthropic direct last in the recommended preset order and reuses the Claude logo', () => {
const recommendedPresetIds = PROVIDER_PRESETS.filter(
(preset) => preset.category === 'recommended'
).map((preset) => preset.id);
expect(recommendedPresetIds.at(-1)).toBe('anthropic');
expect(getPresetById('anthropic')?.icon).toBe('/assets/providers/claude.svg');
});
it('only references provider preset icons that exist in ui/public', () => {
for (const preset of PROVIDER_PRESETS) {
if (!preset.icon) continue;
const iconPath = resolve(import.meta.dir, '../../../ui/public', preset.icon.replace(/^\/+/, ''));
expect(existsSync(iconPath)).toBe(true);
}
});
});
@@ -0,0 +1,286 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
afterEach(() => {
mock.restore();
});
function createCodexSettingsFixture(haikuModel: string = 'gpt-5-codex-mini'): {
tmpDir: string;
settingsPath: string;
} {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-plan-compat-'));
const settingsPath = path.join(tmpDir, 'codex.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'gpt-5.3-codex',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex',
ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel,
},
},
null,
2
),
'utf-8'
);
return { tmpDir, settingsPath };
}
async function importCompatibilityModule(cacheTag: string) {
return import(`../../../src/cliproxy/codex-plan-compatibility?${cacheTag}=${Date.now()}`);
}
describe('codex plan compatibility reconcile', () => {
it('repairs stale paid-only Codex settings for free-plan accounts before launch', async () => {
const { tmpDir, settingsPath } = createCodexSettingsFixture('gpt-5.3-codex-spark');
mock.module('../../../src/cliproxy/account-manager', () => ({
getDefaultAccount: () => ({ id: 'free@example.com' }),
}));
mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({
fetchCodexQuota: async () => ({
success: true,
windows: [],
coreUsage: { fiveHour: null, weekly: null },
planType: 'free',
lastUpdated: Date.now(),
accountId: 'free@example.com',
}),
}));
mock.module('../../../src/cliproxy/quota-response-cache', () => ({
getCachedQuota: () => null,
setCachedQuota: () => {},
}));
mock.module('../../../src/utils/ui', () => ({
info: (message: string) => message,
warn: (message: string) => message,
}));
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('free-plan');
await reconcileCodexModelForActivePlan({
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
});
const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>;
};
expect(repaired.env.ANTHROPIC_MODEL).toBe('gpt-5-codex');
expect(repaired.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5-codex');
expect(repaired.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5-codex');
expect(repaired.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini');
expect(errorSpy).toHaveBeenCalledWith(
'Codex free plan detected. Switched unsupported model "gpt-5.3-codex" to "gpt-5-codex".'
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('warns and leaves settings untouched when no default Codex account is available', async () => {
const { tmpDir, settingsPath } = createCodexSettingsFixture();
mock.module('../../../src/cliproxy/account-manager', () => ({
getDefaultAccount: () => null,
}));
mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({
fetchCodexQuota: async () => {
throw new Error('should not fetch quota without a default account');
},
}));
mock.module('../../../src/cliproxy/quota-response-cache', () => ({
getCachedQuota: () => null,
setCachedQuota: () => {},
}));
mock.module('../../../src/utils/ui', () => ({
info: (message: string) => message,
warn: (message: string) => message,
}));
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
const { reconcileCodexModelForActivePlan } =
await importCompatibilityModule('missing-default-account');
await reconcileCodexModelForActivePlan({
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
});
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>;
};
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(errorSpy).toHaveBeenCalledWith(
'Configured Codex model "gpt-5.3-codex" may require a paid Codex plan. If startup fails, switch to "gpt-5-codex" with "ccs codex --config".'
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('keeps paid-plan Codex settings unchanged for plus and team accounts', async () => {
for (const planType of ['plus', 'team'] as const) {
const { tmpDir, settingsPath } = createCodexSettingsFixture();
mock.module('../../../src/cliproxy/account-manager', () => ({
getDefaultAccount: () => ({ id: `${planType}@example.com` }),
}));
mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({
fetchCodexQuota: async () => ({
success: true,
windows: [],
coreUsage: { fiveHour: null, weekly: null },
planType,
lastUpdated: Date.now(),
accountId: `${planType}@example.com`,
}),
}));
mock.module('../../../src/cliproxy/quota-response-cache', () => ({
getCachedQuota: () => null,
setCachedQuota: () => {},
}));
mock.module('../../../src/utils/ui', () => ({
info: (message: string) => message,
warn: (message: string) => message,
}));
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
const { reconcileCodexModelForActivePlan } = await importCompatibilityModule(planType);
await reconcileCodexModelForActivePlan({
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
});
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>;
};
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(errorSpy).not.toHaveBeenCalled();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
mock.restore();
}
}
});
it('warns and keeps settings unchanged when Codex plan verification fails', async () => {
const { tmpDir, settingsPath } = createCodexSettingsFixture();
mock.module('../../../src/cliproxy/account-manager', () => ({
getDefaultAccount: () => ({ id: 'unknown@example.com' }),
}));
mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({
fetchCodexQuota: async () => ({
success: false,
windows: [],
coreUsage: { fiveHour: null, weekly: null },
planType: null,
lastUpdated: Date.now(),
accountId: 'unknown@example.com',
error: 'network timeout',
}),
}));
mock.module('../../../src/cliproxy/quota-response-cache', () => ({
getCachedQuota: () => null,
setCachedQuota: () => {},
}));
mock.module('../../../src/utils/ui', () => ({
info: (message: string) => message,
warn: (message: string) => message,
}));
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('unknown-plan');
await reconcileCodexModelForActivePlan({
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
});
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>;
};
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(errorSpy).toHaveBeenCalledWith(
'Could not verify Codex plan for model "gpt-5.3-codex". If startup fails with model_not_supported, switch to "gpt-5-codex" via "ccs codex --config".'
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('warns and keeps settings unchanged when quota succeeds without a plan type', async () => {
const { tmpDir, settingsPath } = createCodexSettingsFixture();
mock.module('../../../src/cliproxy/account-manager', () => ({
getDefaultAccount: () => ({ id: 'missing-plan@example.com' }),
}));
mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({
fetchCodexQuota: async () => ({
success: true,
windows: [],
coreUsage: { fiveHour: null, weekly: null },
planType: null,
lastUpdated: Date.now(),
accountId: 'missing-plan@example.com',
}),
}));
mock.module('../../../src/cliproxy/quota-response-cache', () => ({
getCachedQuota: () => null,
setCachedQuota: () => {},
}));
mock.module('../../../src/utils/ui', () => ({
info: (message: string) => message,
warn: (message: string) => message,
}));
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
try {
const { reconcileCodexModelForActivePlan } =
await importCompatibilityModule('missing-plan-type');
await reconcileCodexModelForActivePlan({
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
});
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>;
};
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini');
expect(errorSpy).toHaveBeenCalledWith(
'Could not verify Codex plan for model "gpt-5.3-codex". If startup fails with model_not_supported, switch to "gpt-5-codex" via "ccs codex --config".'
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'bun:test';
import { getProviderCatalog, getModelMaxLevel } from '../../../src/cliproxy/model-catalog';
import {
getDefaultCodexModel,
getFreePlanFallbackCodexModel,
} from '../../../src/cliproxy/codex-plan-compatibility';
describe('codex plan compatibility', () => {
it('uses a cross-plan safe Codex default', () => {
expect(getDefaultCodexModel()).toBe('gpt-5-codex');
expect(getProviderCatalog('codex')?.defaultModel).toBe('gpt-5-codex');
});
it('maps paid-only free-plan models to safe fallbacks', () => {
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex')).toBe('gpt-5-codex');
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-xhigh')).toBe('gpt-5-codex');
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex(high)')).toBe('gpt-5-codex');
expect(getFreePlanFallbackCodexModel('gpt-5.4')).toBe('gpt-5-codex');
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-spark')).toBe('gpt-5-codex-mini');
});
it('does not rewrite cross-plan or already-safe Codex models', () => {
expect(getFreePlanFallbackCodexModel('gpt-5-codex')).toBeNull();
expect(getFreePlanFallbackCodexModel('gpt-5.2-codex')).toBeNull();
expect(getFreePlanFallbackCodexModel('gpt-5.1-codex-mini')).toBeNull();
});
it('tracks Codex thinking caps for current safe defaults and paid models', () => {
expect(getModelMaxLevel('codex', 'gpt-5-codex')).toBe('high');
expect(getModelMaxLevel('codex', 'gpt-5-codex-mini')).toBe('high');
expect(getModelMaxLevel('codex', 'gpt-5.2-codex')).toBe('xhigh');
expect(getModelMaxLevel('codex', 'gpt-5.3-codex')).toBe('xhigh');
});
});
@@ -112,7 +112,7 @@ cliproxy:
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.1-codex-mini');
expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.1-codex-mini');
expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.1-codex-mini');
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini');
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini');
expect(settings.env.CUSTOM_FLAG).toBe('keep-me');
expect(settings.hooks.PreToolUse.length).toBe(1);
@@ -134,7 +134,7 @@ cliproxy:
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini');
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini');
const modelOnly = updateVariant('demo', { model: 'gpt-5.3-codex' });
expect(modelOnly.success).toBe(true);
@@ -145,6 +145,6 @@ cliproxy:
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini');
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini');
});
});
+68 -2
View File
@@ -1,6 +1,9 @@
import { describe, expect, test } from 'bun:test';
import { parseApiCommandArgs } from '../../../src/commands/api-command';
import {
collectUnexpectedApiArgs,
parseApiCommandArgs,
} from '../../../src/commands/api-command/shared';
describe('api-command arg parser', () => {
test('keeps positional API name when boolean flags precede it', () => {
@@ -56,7 +59,9 @@ describe('api-command arg parser', () => {
const parsed = parseApiCommandArgs(['my-api', '--target', 'invalid-target']);
expect(parsed.target).toBeUndefined();
expect(parsed.errors).toEqual(['Invalid --target value "invalid-target". Use: claude or droid']);
expect(parsed.errors).toEqual([
'Invalid --target value "invalid-target". Use: claude or droid',
]);
});
test('collects missing-value error for --target with no value', () => {
@@ -80,4 +85,65 @@ describe('api-command arg parser', () => {
expect(parsed.target).toBe('droid');
expect(parsed.errors).toEqual([]);
});
test('collects unknown options and unexpected trailing positionals', () => {
const parsed = parseApiCommandArgs(['my-api', '--taret', 'droid', '--yes']);
expect(parsed.name).toBe('my-api');
expect(parsed.errors).toEqual(['Unknown option: --taret', 'Unexpected arguments: droid']);
});
test('rejects extra positionals for single-name commands by default', () => {
const parsed = parseApiCommandArgs(['source', 'destination', '--yes']);
expect(parsed.positionals).toEqual(['source', 'destination']);
expect(parsed.errors).toEqual(['Unexpected arguments: destination']);
});
test('allows copy-style two-positional parsing when requested', () => {
const parsed = parseApiCommandArgs(['source', 'destination', '--yes'], {
maxPositionals: 2,
});
expect(parsed.positionals).toEqual(['source', 'destination']);
expect(parsed.errors).toEqual([]);
});
test('preserves dash-prefixed names after option terminator', () => {
const parsed = parseApiCommandArgs(['--yes', '--', '-my-api', 'backup'], {
maxPositionals: 2,
});
expect(parsed.positionals).toEqual(['-my-api', 'backup']);
expect(parsed.errors).toEqual([]);
});
test('accepts single-dash model values without treating them as unknown flags', () => {
const parsed = parseApiCommandArgs(['my-api', '--model', '-preview']);
expect(parsed.model).toBe('-preview');
expect(parsed.errors).toEqual([]);
});
});
describe('collectUnexpectedApiArgs', () => {
test('rejects extra args after a no-arg command', () => {
const parsed = collectUnexpectedApiArgs(['--register', 'extra'], {
knownFlags: ['--register'],
maxPositionals: 0,
});
expect(parsed.positionals).toEqual(['extra']);
expect(parsed.errors).toEqual(['Unexpected arguments: extra']);
});
test('reports unknown flags separately from leftover positionals', () => {
const parsed = collectUnexpectedApiArgs(['--bogus', 'value', '--yes'], {
knownFlags: ['--yes'],
maxPositionals: 0,
});
expect(parsed.positionals).toEqual(['value']);
expect(parsed.errors).toEqual(['Unknown option: --bogus', 'Unexpected arguments: value']);
});
});
@@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
let calls: string[] = [];
beforeEach(() => {
calls = [];
mock.module('../../../src/commands/api-command/help', () => ({
showApiCommandHelp: async () => {
calls.push('help');
},
showUnknownApiCommand: async (command: string) => {
calls.push(`unknown:${command}`);
},
}));
mock.module('../../../src/commands/api-command/create-command', () => ({
handleApiCreateCommand: async (args: string[]) => {
calls.push(`create:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/api-command/list-command', () => ({
handleApiListCommand: async (args: string[]) => {
calls.push(`list:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/api-command/remove-command', () => ({
handleApiRemoveCommand: async (args: string[]) => {
calls.push(`remove:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/api-command/discover-command', () => ({
handleApiDiscoverCommand: async (args: string[]) => {
calls.push(`discover:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/api-command/copy-command', () => ({
handleApiCopyCommand: async (args: string[]) => {
calls.push(`copy:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/api-command/export-command', () => ({
handleApiExportCommand: async (args: string[]) => {
calls.push(`export:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/api-command/import-command', () => ({
handleApiImportCommand: async (args: string[]) => {
calls.push(`import:${args.join(' ')}`);
},
}));
});
afterEach(() => {
mock.restore();
});
async function loadHandleApiCommand() {
const mod = await import(`../../../src/commands/api-command?test=${Date.now()}-${Math.random()}`);
return mod.handleApiCommand;
}
describe('api-command router', () => {
it('defaults to help when no subcommand is provided', async () => {
const handleApiCommand = await loadHandleApiCommand();
await handleApiCommand([]);
expect(calls).toEqual(['help']);
});
it('routes remove aliases through the named command dispatcher', async () => {
const handleApiCommand = await loadHandleApiCommand();
await handleApiCommand(['rm', 'profile-a']);
expect(calls).toEqual(['remove:profile-a']);
});
it('forwards list arguments to the handler for validation', async () => {
const handleApiCommand = await loadHandleApiCommand();
await handleApiCommand(['list', 'unexpected']);
expect(calls).toEqual(['list:unexpected']);
});
it('routes hardened subcommands through their handlers', async () => {
const handleApiCommand = await loadHandleApiCommand();
await handleApiCommand(['discover', '--json']);
await handleApiCommand(['copy', 'source', 'dest']);
await handleApiCommand(['export', 'profile-a', '--out', 'backup.json']);
await handleApiCommand(['import', 'bundle.json', '--force']);
expect(calls).toEqual([
'discover:--json',
'copy:source dest',
'export:profile-a --out backup.json',
'import:bundle.json --force',
]);
});
it('delegates unknown commands to the shared unknown handler', async () => {
const handleApiCommand = await loadHandleApiCommand();
await handleApiCommand(['bogus']);
expect(calls).toEqual(['unknown:bogus']);
});
});
@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
let tempDir = '';
let originalCwd = '';
let originalConsoleLog: typeof console.log;
let logLines: string[] = [];
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'ccs-api-export-test-'));
originalCwd = process.cwd();
process.chdir(tempDir);
logLines = [];
originalConsoleLog = console.log;
console.log = (...args: unknown[]) => {
logLines.push(args.map(String).join(' '));
};
const uiModule = {
initUI: async () => {},
header: (message: string) => message,
subheader: (message: string) => message,
color: (message: string) => message,
dim: (message: string) => message,
ok: (message: string) => message,
info: (message: string) => message,
warn: (message: string) => message,
fail: (message: string) => message,
};
mock.module('../../../src/utils/ui', () => uiModule);
mock.module('../../../src/utils/ui.ts', () => uiModule);
mock.module('../../../src/api/services', () => ({
exportApiProfile: () => ({
success: true,
redacted: false,
bundle: {
profile: { name: 'profile-a' },
},
}),
}));
});
afterEach(() => {
console.log = originalConsoleLog;
process.chdir(originalCwd);
rmSync(tempDir, { recursive: true, force: true });
mock.restore();
});
async function loadHandleApiExportCommand() {
const mod = await import(
`../../../src/commands/api-command/export-command?test=${Date.now()}-${Math.random()}`
);
return mod.handleApiExportCommand;
}
describe('api export command', () => {
it('accepts dash-prefixed output paths', async () => {
const handleApiExportCommand = await loadHandleApiExportCommand();
await handleApiExportCommand(['profile-a', '--out', '--snapshot.json']);
const outputPath = resolve(process.cwd(), '--snapshot.json');
expect(existsSync(outputPath)).toBe(true);
expect(readFileSync(outputPath, 'utf8')).toContain('"name": "profile-a"');
expect(logLines.join('\n')).toContain(`Profile exported to: ${outputPath}`);
});
});
+88 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test';
import { extractOption, hasAnyFlag } from '../../../src/commands/arg-extractor';
import { extractOption, hasAnyFlag, scanCommandArgs } from '../../../src/commands/arg-extractor';
describe('arg-extractor', () => {
describe('extractOption', () => {
@@ -70,6 +70,21 @@ describe('arg-extractor', () => {
});
});
it('accepts long dash-prefixed values when allowLongDashValue is enabled', () => {
const result = extractOption(['--out', '--snapshot.json', '--yes'], ['--out'], {
allowDashValue: true,
allowLongDashValue: true,
knownFlags: ['--out', '--yes'],
});
expect(result).toEqual({
found: true,
value: '--snapshot.json',
missingValue: false,
remainingArgs: ['--yes'],
});
});
it('still treats known flags as missing when allowDashValue is enabled', () => {
const result = extractOption(['--model', '--yes', 'prompt'], ['--model'], {
allowDashValue: true,
@@ -83,6 +98,20 @@ describe('arg-extractor', () => {
});
});
it('still treats known long flags as missing when allowLongDashValue is enabled', () => {
const result = extractOption(['--out', '--yes', 'prompt'], ['--out'], {
allowDashValue: true,
allowLongDashValue: true,
knownFlags: ['--out', '--yes'],
});
expect(result).toEqual({
found: true,
missingValue: true,
remainingArgs: ['--yes', 'prompt'],
});
});
it('supports repeated extraction loops with deterministic last-value wins behavior', () => {
let remaining = ['--model', 'gpt-4.1-mini', '--model', 'gpt-4.1'];
let selected: string | undefined;
@@ -133,4 +162,62 @@ describe('arg-extractor', () => {
expect(hasAnyFlag(['prompt', '--profile=gemini'], ['--yes', '-y'])).toBe(false);
});
});
describe('scanCommandArgs', () => {
it('collects positionals and unknown flags while ignoring known boolean flags', () => {
const result = scanCommandArgs(['profile-a', '--yes', '--bogus', 'extra'], {
knownFlags: ['--yes'],
});
expect(result).toEqual({
positionals: ['profile-a', 'extra'],
unknownFlags: ['--bogus'],
});
});
it('skips values for known value flags', () => {
const result = scanCommandArgs(['--model', 'claude-3-7-sonnet', 'profile-a', '--force'], {
knownFlags: ['--model', '--force'],
valueFlags: ['--model'],
});
expect(result).toEqual({
positionals: ['profile-a'],
unknownFlags: [],
});
});
it('preserves option-terminator positionals', () => {
const result = scanCommandArgs(['--yes', '--', '--literal-name'], {
knownFlags: ['--yes'],
});
expect(result).toEqual({
positionals: ['--literal-name'],
unknownFlags: [],
});
});
it('consumes single-dash values but not long-flag lookalikes when allowDashValue is enabled', () => {
const singleDashValue = scanCommandArgs(['--model', '-preview', '--yes'], {
knownFlags: ['--model', '--yes'],
valueFlags: ['--model'],
allowDashValue: true,
});
const longFlagLookalike = scanCommandArgs(['--model', '--target', 'droid'], {
knownFlags: ['--model', '--target'],
valueFlags: ['--model'],
allowDashValue: true,
});
expect(singleDashValue).toEqual({
positionals: [],
unknownFlags: [],
});
expect(longFlagLookalike).toEqual({
positionals: ['droid'],
unknownFlags: [],
});
});
});
});
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
let calls: string[] = [];
let logLines: string[] = [];
let originalConsoleLog: typeof console.log;
let originalProcessExit: typeof process.exit;
beforeEach(() => {
calls = [];
logLines = [];
originalConsoleLog = console.log;
originalProcessExit = process.exit;
console.log = (...args: unknown[]) => {
logLines.push(args.map(String).join(' '));
};
const uiModule = {
initUI: async () => {},
header: (message: string) => message,
subheader: (message: string) => message,
color: (message: string) => message,
dim: (message: string) => message,
ok: (message: string) => message,
info: (message: string) => message,
warn: (message: string) => message,
fail: (message: string) => message,
};
mock.module('../../../src/utils/ui', () => uiModule);
mock.module('../../../src/utils/ui.ts', () => uiModule);
mock.module('../../../src/commands/config-auth/setup-command', () => ({
handleSetup: async () => {
calls.push('setup');
},
}));
mock.module('../../../src/commands/config-auth/show-command', () => ({
handleShow: async () => {
calls.push('show');
},
}));
mock.module('../../../src/commands/config-auth/disable-command', () => ({
handleDisable: async () => {
calls.push('disable');
},
}));
});
afterEach(() => {
console.log = originalConsoleLog;
process.exit = originalProcessExit;
mock.restore();
});
async function loadHandleConfigAuthCommand() {
const mod = await import(
`../../../src/commands/config-auth?test=${Date.now()}-${Math.random()}`
);
return mod.handleConfigAuthCommand;
}
describe('config-auth command routing', () => {
it('routes the status alias to show', async () => {
const handleConfigAuthCommand = await loadHandleConfigAuthCommand();
await handleConfigAuthCommand(['status']);
expect(calls).toEqual(['show']);
});
it('keeps auth help available', async () => {
const handleConfigAuthCommand = await loadHandleConfigAuthCommand();
await handleConfigAuthCommand(['--help']);
expect(calls).toEqual([]);
expect(logLines.join('\n')).toContain('Dashboard Auth Management');
});
it('rejects trailing arguments for zero-arg subcommands', async () => {
const handleConfigAuthCommand = await loadHandleConfigAuthCommand();
process.exit = ((code?: number) => {
throw new Error(`process.exit(${code ?? 0})`);
}) as typeof process.exit;
await expect(handleConfigAuthCommand(['disable', 'now'])).rejects.toThrow('process.exit(1)');
expect(calls).toEqual([]);
expect(logLines.join('\n')).toContain('Unexpected arguments for "config auth disable": now');
});
});
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'bun:test';
import { parseConfigCommandArgs } from '../../../src/commands/config-command-options';
describe('config command options parser', () => {
it('defaults to system bind behavior without explicit host', () => {
const result = parseConfigCommandArgs([]);
expect(result.help).toBe(false);
expect(result.error).toBeUndefined();
expect(result.options.host).toBeUndefined();
expect(result.options.hostProvided).toBe(false);
});
it('parses explicit host and port overrides', () => {
const result = parseConfigCommandArgs(['--host', '0.0.0.0', '--port', '4100']);
expect(result.error).toBeUndefined();
expect(result.options.host).toBe('0.0.0.0');
expect(result.options.hostProvided).toBe(true);
expect(result.options.port).toBe(4100);
});
it('rejects missing host values', () => {
const result = parseConfigCommandArgs(['--host']);
expect(result.error).toBe('Invalid host value');
});
it('accepts port 65535 and rejects port 65536', () => {
const accepted = parseConfigCommandArgs(['--port', '65535']);
const rejected = parseConfigCommandArgs(['--port', '65536']);
expect(accepted.error).toBeUndefined();
expect(accepted.options.port).toBe(65535);
expect(rejected.error).toBe('Invalid port number');
});
it('rejects unknown options', () => {
const result = parseConfigCommandArgs(['--hst', '0.0.0.0']);
expect(result.error).toBe('Unexpected arguments: --hst 0.0.0.0');
});
it('rejects unexpected trailing positionals', () => {
const result = parseConfigCommandArgs(['--port', '3000', 'extra']);
expect(result.error).toBe('Unexpected arguments: extra');
});
});
+244
View File
@@ -0,0 +1,244 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
const startServerCalls: Array<Record<string, unknown>> = [];
const resolveDashboardUrlsCalls: Array<[string | undefined, number]> = [];
const configAuthCalls: string[][] = [];
let logLines: string[] = [];
let errorLines: string[] = [];
let dashboardAuthEnabled = false;
let startServerError: Error | null = null;
let mockServerBindHost = '::';
let originalConsoleLog: typeof console.log;
let originalConsoleError: typeof console.error;
let originalProcessExit: typeof process.exit;
beforeEach(() => {
startServerCalls.length = 0;
resolveDashboardUrlsCalls.length = 0;
configAuthCalls.length = 0;
logLines = [];
errorLines = [];
dashboardAuthEnabled = false;
startServerError = null;
mockServerBindHost = '::';
originalConsoleLog = console.log;
originalConsoleError = console.error;
originalProcessExit = process.exit;
console.log = (...args: unknown[]) => {
logLines.push(args.map(String).join(' '));
};
console.error = (...args: unknown[]) => {
errorLines.push(args.map(String).join(' '));
};
mock.module('get-port', () => ({
default: async () => 3000,
}));
mock.module('open', () => ({
default: async () => undefined,
}));
mock.module('../../../src/web-server', () => ({
startServer: async (options: Record<string, unknown>) => {
startServerCalls.push({ ...options });
if (startServerError) {
throw startServerError;
}
return {
server: {
address: () => ({ address: mockServerBindHost }),
} as never,
wss: {} as never,
cleanup: () => {},
};
},
}));
mock.module('../../../src/web-server/shutdown', () => ({
setupGracefulShutdown: () => {},
}));
mock.module('../../../src/cliproxy/service-manager', () => ({
ensureCliproxyService: async () => ({
started: true,
alreadyRunning: true,
port: 8317,
configRegenerated: false,
}),
}));
mock.module('../../../src/cliproxy/config-generator', () => ({
CLIPROXY_DEFAULT_PORT: 8317,
}));
mock.module('../../../src/config/unified-config-loader', () => ({
getDashboardAuthConfig: () => ({
enabled: dashboardAuthEnabled,
}),
}));
const uiModule = {
initUI: async () => {},
header: (message: string) => message,
ok: (message: string) => message,
info: (message: string) => message,
warn: (message: string) => message,
fail: (message: string) => message,
};
mock.module('../../../src/utils/ui', () => uiModule);
mock.module('../../../src/utils/ui.ts', () => uiModule);
mock.module('../../../src/commands/config-dashboard-host', () => ({
normalizeDashboardHost: (host: string | undefined) => {
if (!host) {
return undefined;
}
if (host.startsWith('[') && host.endsWith(']') && host.includes(':')) {
return host.slice(1, -1);
}
return host;
},
isLoopbackHost: (host: string) =>
['localhost', '127.0.0.1', '::1', '[::1]'].includes(host.trim().toLowerCase()),
isWildcardHost: (host: string) => ['0.0.0.0', '::', '[::]'].includes(host.trim().toLowerCase()),
resolveDashboardUrls: (host: string | undefined, port: number) => {
resolveDashboardUrlsCalls.push([host, port]);
if (!host) {
return { browserUrl: `http://localhost:${port}` };
}
if (host === '0.0.0.0' || host === '::') {
return {
bindHost: host,
browserUrl: `http://localhost:${port}`,
networkUrls: [`http://192.168.1.25:${port}`, `http://100.64.0.12:${port}`],
};
}
return {
bindHost: host,
browserUrl: `http://${host}:${port}`,
};
},
}));
mock.module('../../../src/commands/config-auth', () => ({
handleConfigAuthCommand: async (args: string[]) => {
configAuthCalls.push([...args]);
},
}));
});
afterEach(() => {
console.log = originalConsoleLog;
console.error = originalConsoleError;
process.exit = originalProcessExit;
mock.restore();
});
async function loadHandleConfigCommand() {
const mod = await import(
`../../../src/commands/config-command?test=${Date.now()}-${Math.random()}`
);
return mod.handleConfigCommand;
}
describe('config command dashboard startup', () => {
it('shows help for literal help token instead of starting the dashboard', async () => {
const handleConfigCommand = await loadHandleConfigCommand();
process.exit = ((code?: number) => {
throw new Error(`process.exit(${code ?? 0})`);
}) as typeof process.exit;
await expect(handleConfigCommand(['help'])).rejects.toThrow('process.exit(0)');
expect(startServerCalls).toHaveLength(0);
expect(resolveDashboardUrlsCalls).toHaveLength(0);
expect(logLines.join('\n')).toContain('Usage: ccs config [command] [options]');
});
it('routes auth subcommands before dashboard startup', async () => {
const handleConfigCommand = await loadHandleConfigCommand();
await handleConfigCommand(['auth', 'setup']);
expect(configAuthCalls).toEqual([['setup']]);
expect(startServerCalls).toHaveLength(0);
expect(resolveDashboardUrlsCalls).toHaveLength(0);
});
it('rejects unknown config subcommands before dashboard startup', async () => {
const handleConfigCommand = await loadHandleConfigCommand();
process.exit = ((code?: number) => {
throw new Error(`process.exit(${code ?? 0})`);
}) as typeof process.exit;
await expect(handleConfigCommand(['bogus'])).rejects.toThrow('process.exit(1)');
expect(startServerCalls).toHaveLength(0);
expect(resolveDashboardUrlsCalls).toHaveLength(0);
expect(errorLines.join('\n')).toContain('Unexpected arguments: bogus');
});
it('keeps the default startup path free of an explicit host override', async () => {
const handleConfigCommand = await loadHandleConfigCommand();
await handleConfigCommand([]);
expect(startServerCalls).toHaveLength(1);
expect(startServerCalls[0]).toEqual({ port: 3000, dev: false });
expect(resolveDashboardUrlsCalls).toEqual([['::', 3000]]);
const rendered = logLines.join('\n');
expect(rendered).toContain('Dashboard: http://localhost:3000');
expect(rendered).toContain('Bind host: ::');
expect(rendered).toContain('Network URLs:');
expect(rendered).toContain('Protect it before sharing: ccs config auth setup');
expect(errorLines).toHaveLength(0);
});
it('passes explicit wildcard hosts through and prints exposure guidance', async () => {
const handleConfigCommand = await loadHandleConfigCommand();
mockServerBindHost = '0.0.0.0';
await handleConfigCommand(['--host', '0.0.0.0', '--port', '4100']);
expect(startServerCalls).toHaveLength(1);
expect(startServerCalls[0]).toEqual({ port: 4100, dev: false, host: '0.0.0.0' });
expect(resolveDashboardUrlsCalls).toEqual([['0.0.0.0', 4100]]);
const rendered = logLines.join('\n');
expect(rendered).toContain('Bind host: 0.0.0.0');
expect(rendered).toContain('Network URLs:');
expect(rendered).toContain('http://192.168.1.25:4100');
expect(rendered).toContain('http://100.64.0.12:4100');
expect(rendered).toContain(
'Dashboard may be reachable from other devices that can connect to this machine.'
);
expect(rendered).toContain('Protect it before sharing: ccs config auth setup');
expect(errorLines).toHaveLength(0);
});
it('fails cleanly when the server cannot bind the requested host', async () => {
const handleConfigCommand = await loadHandleConfigCommand();
startServerError = new Error(
'Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use'
);
process.exit = ((code?: number) => {
throw new Error(`process.exit(${code ?? 0})`);
}) as typeof process.exit;
await expect(handleConfigCommand(['--host', '192.0.2.123', '--port', '4100'])).rejects.toThrow(
'process.exit(1)'
);
expect(errorLines.join('\n')).toContain(
'Failed to start server: Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use'
);
});
});
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'bun:test';
import {
isLoopbackHost,
isWildcardHost,
normalizeDashboardHost,
resolveDashboardUrls,
} from '../../../src/commands/config-dashboard-host';
describe('config dashboard host helpers', () => {
it('detects loopback and wildcard hosts', () => {
expect(isLoopbackHost('localhost')).toBe(true);
expect(isLoopbackHost('127.0.0.1')).toBe(true);
expect(isLoopbackHost('::1')).toBe(true);
expect(isLoopbackHost('[::1]')).toBe(true);
expect(isWildcardHost('0.0.0.0')).toBe(true);
expect(isWildcardHost('::')).toBe(true);
expect(isWildcardHost('[::]')).toBe(true);
});
it('returns localhost browser URL without network details when host is omitted', () => {
const urls = resolveDashboardUrls(undefined, 3000, {});
expect(urls.bindHost).toBeUndefined();
expect(urls.browserUrl).toBe('http://localhost:3000');
expect(urls.networkUrls).toBeUndefined();
});
it('returns localhost browser URL and all detected external URLs for wildcard host', () => {
const urls = resolveDashboardUrls('0.0.0.0', 3000, {
en0: [
{
address: '192.168.1.25',
netmask: '255.255.255.0',
family: 'IPv4',
mac: '00:00:00:00:00:00',
internal: false,
cidr: '192.168.1.25/24',
},
],
utun5: [
{
address: '100.64.0.12',
family: 'IPv4',
internal: false,
},
],
});
expect(urls.browserUrl).toBe('http://localhost:3000');
expect(urls.networkUrls).toEqual(['http://192.168.1.25:3000', 'http://100.64.0.12:3000']);
});
it('returns explicit host URL for loopback bindings', () => {
const urls = resolveDashboardUrls('127.0.0.1', 3000, {});
expect(urls.bindHost).toBe('127.0.0.1');
expect(urls.browserUrl).toBe('http://127.0.0.1:3000');
expect(urls.networkUrls).toBeUndefined();
});
it('normalizes bracketed IPv6 host literals for binding and URL output', () => {
const urls = resolveDashboardUrls('[::1]', 3000, {});
expect(normalizeDashboardHost('[::1]')).toBe('::1');
expect(urls.bindHost).toBe('::1');
expect(urls.browserUrl).toBe('http://[::1]:3000');
});
});
@@ -41,6 +41,19 @@ describe('help command parity', () => {
expect(rendered.includes('http://127.0.0.1:8080')).toBe(true);
});
test('root help no longer markets glmt as a supported profile', async () => {
const lines: string[] = [];
console.log = (...args: unknown[]) => {
lines.push(args.map((arg) => String(arg)).join(' '));
};
await handleHelpCommand();
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('ccs glmt')).toBe(false);
expect(rendered.includes('ccs glm')).toBe(true);
});
test('root help documents Claude IDE extension setup surfaces', async () => {
const lines: string[] = [];
console.log = (...args: unknown[]) => {
@@ -58,4 +71,17 @@ describe('help command parity', () => {
true
);
});
test('root help documents dashboard host binding example', async () => {
const lines: string[] = [];
console.log = (...args: unknown[]) => {
lines.push(args.map((arg) => String(arg)).join(' '));
};
await handleHelpCommand();
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('ccs config --host 0.0.0.0')).toBe(true);
expect(rendered.includes('Force all-interface binding for remote devices')).toBe(true);
});
});
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
let calls: string[] = [];
let logLines: string[] = [];
let originalConsoleLog: typeof console.log;
let originalProcessExit: typeof process.exit;
beforeEach(() => {
calls = [];
logLines = [];
originalConsoleLog = console.log;
originalProcessExit = process.exit;
console.log = (...args: unknown[]) => {
logLines.push(args.map(String).join(' '));
};
mock.module('../../../src/commands/version-command', () => ({
handleVersionCommand: async () => {
calls.push('version');
},
}));
mock.module('../../../src/commands/update-command', () => ({
handleUpdateCommand: async (options: Record<string, unknown>) => {
calls.push(`update:${JSON.stringify(options)}`);
},
}));
mock.module('../../../src/commands/api-command', () => ({
handleApiCommand: async (args: string[]) => {
calls.push(`api:${args.join(' ')}`);
},
}));
mock.module('../../../src/commands/tokens-command', () => ({
handleTokensCommand: async () => 37,
}));
});
afterEach(() => {
console.log = originalConsoleLog;
process.exit = originalProcessExit;
mock.restore();
});
async function loadTryHandleRootCommand() {
const mod = await import(
`../../../src/commands/root-command-router?test=${Date.now()}-${Math.random()}`
);
return mod.tryHandleRootCommand;
}
describe('root-command-router', () => {
it('routes command aliases to their handlers', async () => {
const tryHandleRootCommand = await loadTryHandleRootCommand();
await expect(tryHandleRootCommand(['--version'])).resolves.toBe(true);
expect(calls).toEqual(['version']);
});
it('returns false for profile-like tokens that are not root commands', async () => {
const tryHandleRootCommand = await loadTryHandleRootCommand();
await expect(tryHandleRootCommand(['glm'])).resolves.toBe(false);
expect(calls).toEqual([]);
});
it('prints update help without invoking the updater', async () => {
const tryHandleRootCommand = await loadTryHandleRootCommand();
await expect(tryHandleRootCommand(['update', '--help'])).resolves.toBe(true);
expect(calls).toEqual([]);
expect(logLines.join('\n')).toContain('Usage: ccs update [options]');
expect(logLines.join('\n')).toContain('ccs update --beta');
});
it('passes remaining args through to nested command handlers', async () => {
const tryHandleRootCommand = await loadTryHandleRootCommand();
await expect(tryHandleRootCommand(['api', 'discover', '--register'])).resolves.toBe(true);
expect(calls).toEqual(['api:discover --register']);
});
it('exits with the nested command exit code when required', async () => {
process.exit = ((code?: number) => {
throw new Error(`process.exit(${code ?? 0})`);
}) as typeof process.exit;
const tryHandleRootCommand = await loadTryHandleRootCommand();
await expect(tryHandleRootCommand(['tokens', 'list'])).rejects.toThrow('process.exit(37)');
});
});
@@ -0,0 +1,440 @@
import { describe, expect, it } from 'bun:test';
import { createAnthropicProxyResponse } from '../../../src/cursor/cursor-anthropic-response';
import { translateAnthropicRequest } from '../../../src/cursor/cursor-anthropic-translator';
describe('translateAnthropicRequest', () => {
it('maps Anthropic system, tool use, and tool result blocks into Cursor OpenAI messages', () => {
const translated = translateAnthropicRequest({
model: 'claude-sonnet-4.5',
stream: true,
thinking: { type: 'enabled', budget_tokens: 9000 },
tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }],
system: 'You are helpful.',
messages: [
{ role: 'user', content: [{ type: 'text', text: 'Find release notes' }] },
{
role: 'assistant',
content: [{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'release' } }],
},
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'toolu_1',
content: [{ type: 'text', text: 'v7.53.0' }],
},
{ type: 'text', text: 'Summarize it.' },
],
},
],
});
expect(translated.model).toBe('claude-sonnet-4.5');
expect(translated.stream).toBe(true);
expect(translated.reasoning_effort).toBe('high');
expect(translated.messages).toEqual([
{ role: 'system', content: 'You are helpful.' },
{ role: 'user', content: 'Find release notes' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'toolu_1',
type: 'function',
function: { name: 'search', arguments: '{"q":"release"}' },
},
],
},
{ role: 'tool', tool_call_id: 'toolu_1', content: 'v7.53.0' },
{ role: 'user', content: 'Summarize it.' },
]);
});
it('rejects unsupported content blocks', () => {
expect(() =>
translateAnthropicRequest({
messages: [{ role: 'user', content: [{ type: 'image' }] }],
})
).toThrow('is not supported');
});
it('does not append an empty user message after tool_result-only turns', () => {
const translated = translateAnthropicRequest({
messages: [
{
role: 'assistant',
content: [{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'x' } }],
},
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'done' }],
},
],
});
expect(translated.messages).toEqual([
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'toolu_1',
type: 'function',
function: { name: 'search', arguments: '{"q":"x"}' },
},
],
},
{ role: 'tool', tool_call_id: 'toolu_1', content: 'done' },
]);
});
it('preserves mixed user text around tool_result blocks in order', () => {
const translated = translateAnthropicRequest({
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'before' },
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'done' },
{ type: 'text', text: 'after' },
],
},
],
});
expect(translated.messages).toEqual([
{ role: 'user', content: 'before' },
{ role: 'tool', tool_call_id: 'toolu_1', content: 'done' },
{ role: 'user', content: 'after' },
]);
});
it('handles empty messages arrays', () => {
const translated = translateAnthropicRequest({ messages: [] });
expect(translated.messages).toEqual([]);
});
it('uses a distinct fallback prefix for missing tool_use ids', () => {
const translated = translateAnthropicRequest({
messages: [
{
role: 'assistant',
content: [{ type: 'tool_use', name: 'search', input: { q: 'x' } }],
},
],
});
expect(translated.messages[0]?.tool_calls?.[0]?.id).toBe('toolu_ccs_fallback_0_0');
});
it('falls back when tool_result content cannot be serialized', () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
const translated = translateAnthropicRequest({
messages: [
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: circular }],
},
],
});
expect(translated.messages).toEqual([
{
role: 'tool',
tool_call_id: 'toolu_1',
content: '[unserializable content]',
},
]);
});
it('returns empty string for tool_result blocks without content', () => {
const translated = translateAnthropicRequest({
messages: [
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1' }],
},
],
});
expect(translated.messages).toEqual([
{
role: 'tool',
tool_call_id: 'toolu_1',
content: '',
},
]);
});
it('rejects tool_result blocks without a non-empty tool_use_id', () => {
expect(() =>
translateAnthropicRequest({
messages: [
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: ' ', content: 'done' }],
},
],
})
).toThrow('tool_use_id must be a non-empty string');
});
it('falls back when tool_use input cannot be serialized', () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
const translated = translateAnthropicRequest({
messages: [
{
role: 'assistant',
content: [{ type: 'tool_use', name: 'search', input: circular }],
},
],
});
expect(translated.messages[0]).toEqual({
role: 'assistant',
content: '',
tool_calls: [
{
id: 'toolu_ccs_fallback_0_0',
type: 'function',
function: { name: 'search', arguments: '{}' },
},
],
});
});
});
describe('createAnthropicProxyResponse', () => {
it('converts OpenAI JSON into Anthropic message JSON', async () => {
const response = new Response(
JSON.stringify({
id: 'chatcmpl_1',
model: 'claude-sonnet-4.5',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: 'Here is the result.',
reasoning_content: 'Need to call the tool first.',
tool_calls: [
{
id: 'toolu_2',
type: 'function',
function: { name: 'search', arguments: '{"q":"cursor daemon"}' },
},
],
},
finish_reason: 'tool_calls',
},
],
usage: { prompt_tokens: 12, completion_tokens: 4, total_tokens: 16 },
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
const transformed = await createAnthropicProxyResponse(response);
const body = (await transformed.json()) as {
type: string;
model: string;
stop_reason: string;
content: Array<{
type: string;
text?: string;
thinking?: string;
name?: string;
input?: Record<string, unknown>;
}>;
};
expect(body.type).toBe('message');
expect(body.model).toBe('claude-sonnet-4.5');
expect(body.stop_reason).toBe('tool_use');
expect(body.content.map((block) => block.type)).toEqual(['thinking', 'text', 'tool_use']);
expect(body.content[0]?.thinking).toContain('Need to call the tool first');
expect(body.content[2]?.name).toBe('search');
expect(body.content[2]?.input).toEqual({ q: 'cursor daemon' });
});
it('returns 502 when Cursor returns invalid JSON', async () => {
const transformed = await createAnthropicProxyResponse(
new Response('not json', {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
expect(transformed.status).toBe(502);
const body = (await transformed.json()) as {
type?: string;
error?: { type?: string; message?: string };
};
expect(body.type).toBe('error');
expect(body.error?.type).toBe('api_error');
expect(body.error?.message).toBe('Failed to translate Cursor JSON response');
});
it('returns 502 when Cursor response is missing choices', async () => {
const transformed = await createAnthropicProxyResponse(
new Response(
JSON.stringify({
id: 'chatcmpl_missing_choices',
model: 'claude-sonnet-4.5',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
);
expect(transformed.status).toBe(502);
const body = (await transformed.json()) as {
type?: string;
error?: { type?: string; message?: string };
};
expect(body.type).toBe('error');
expect(body.error?.type).toBe('api_error');
expect(body.error?.message).toBe('Failed to translate Cursor JSON response');
});
it('returns 502 when Cursor response has empty choices', async () => {
const transformed = await createAnthropicProxyResponse(
new Response(
JSON.stringify({
id: 'chatcmpl_empty_choices',
model: 'claude-sonnet-4.5',
choices: [],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
);
expect(transformed.status).toBe(502);
const body = (await transformed.json()) as {
type?: string;
error?: { type?: string; message?: string };
};
expect(body.type).toBe('error');
expect(body.error?.type).toBe('api_error');
expect(body.error?.message).toBe('Failed to translate Cursor JSON response');
});
it('returns Anthropic error envelopes for non-OK upstream JSON errors', async () => {
const transformed = await createAnthropicProxyResponse(
new Response(
JSON.stringify({
error: {
type: 'invalid_request_error',
message: '[400]: upstream rejected request',
},
}),
{
status: 400,
headers: { 'Content-Type': 'application/json', 'Retry-After': '7' },
}
)
);
expect(transformed.status).toBe(400);
expect(transformed.headers.get('retry-after')).toBe('7');
const body = (await transformed.json()) as {
type?: string;
error?: { type?: string; message?: string };
};
expect(body.type).toBe('error');
expect(body.error?.type).toBe('invalid_request_error');
expect(body.error?.message).toBe('[400]: upstream rejected request');
});
it('returns 502 when Cursor response choices are malformed', async () => {
const transformed = await createAnthropicProxyResponse(
new Response(
JSON.stringify({
id: 'chatcmpl_missing_message',
model: 'claude-sonnet-4.5',
choices: [{ index: 0 }],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
);
expect(transformed.status).toBe(502);
const body = (await transformed.json()) as {
type?: string;
error?: { type?: string; message?: string };
};
expect(body.type).toBe('error');
expect(body.error?.type).toBe('api_error');
expect(body.error?.message).toBe('Failed to translate Cursor JSON response');
});
it('converts OpenAI SSE chunks into Anthropic SSE events', async () => {
const openAiSse = [
'data: {"id":"chatcmpl_2","object":"chat.completion.chunk","created":1,"model":"claude-sonnet-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}\n\n',
'data: {"id":"chatcmpl_2","object":"chat.completion.chunk","created":1,"model":"claude-sonnet-4.5","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}}\n\n',
'data: [DONE]\n\n',
].join('');
const transformed = await createAnthropicProxyResponse(
new Response(openAiSse, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
);
const body = await transformed.text();
expect(body).toContain('event: message_start');
expect(body).toContain('event: content_block_start');
expect(body).toContain('"type":"text_delta"');
expect(body).toContain('event: message_stop');
});
it('emits Anthropic-style error events when SSE translation fails', async () => {
const oversizedChunk = `data: ${'x'.repeat(1024 * 1024 + 32)}`;
const transformed = await createAnthropicProxyResponse(
new Response(oversizedChunk, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
);
const body = await transformed.text();
expect(body).toContain('event: error');
expect(body).toContain('"type":"error"');
expect(body).toContain('"error":{"type":"api_error"');
expect(body).toContain('Failed to translate Cursor SSE response');
});
it('emits Anthropic-style error events when SSE JSON is malformed', async () => {
const transformed = await createAnthropicProxyResponse(
new Response('data: {not-json}\n\n', {
status: 200,
headers: { 'Content-Type': 'text/event-stream; charset=utf-8' },
})
);
const body = await transformed.text();
expect(body).toContain('event: error');
expect(body).toContain('"type":"error"');
expect(body).toContain('"error":{"type":"api_error"');
expect(body).toContain('Failed to translate Cursor SSE response');
});
});
+1 -127
View File
@@ -19,7 +19,7 @@ import {
} from '../../../src/cursor/cursor-daemon';
import { getCcsDir } from '../../../src/utils/config-manager';
import { handleCursorCommand } from '../../../src/commands/cursor-command';
import { loadCredentials, saveCredentials } from '../../../src/cursor/cursor-auth';
import { loadCredentials } from '../../../src/cursor/cursor-auth';
// Test isolation
let originalCcsHome: string | undefined;
@@ -140,132 +140,6 @@ describe('startDaemon', () => {
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid port');
});
it('starts and stops daemon successfully', async () => {
const port = 10000 + Math.floor(Math.random() * 50000);
const result = await startDaemon({ port, ghost_mode: true });
expect(result.success).toBe(true);
expect(result.pid).toBeDefined();
// Verify health
const running = await isDaemonRunning(port);
expect(running).toBe(true);
// Verify models endpoint exists and is OpenAI-compatible list shape
const modelsResponse = await fetch(`http://127.0.0.1:${port}/v1/models`);
expect(modelsResponse.status).toBe(200);
const modelsJson = (await modelsResponse.json()) as { object?: string; data?: unknown[] };
expect(modelsJson.object).toBe('list');
expect(Array.isArray(modelsJson.data)).toBe(true);
// Verify chat endpoint exists (requires auth, should not be 404)
const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4.1',
messages: [{ role: 'user', content: 'hello' }],
}),
});
expect(chatResponse.status).toBe(401);
// Stop
const stopResult = await stopDaemon();
expect(stopResult.success).toBe(true);
// Verify stopped
const stillRunning = await isDaemonRunning(port);
expect(stillRunning).toBe(false);
},
35000
);
it('returns 404 for unknown routes', async () => {
const port = 10000 + Math.floor(Math.random() * 50000);
const result = await startDaemon({ port, ghost_mode: true });
expect(result.success).toBe(true);
try {
const response = await fetch(`http://127.0.0.1:${port}/unknown`);
expect(response.status).toBe(404);
} finally {
await stopDaemon();
}
});
it('returns 401 when credentials are expired', async () => {
const port = 10000 + Math.floor(Math.random() * 50000);
const expiredAt = new Date(Date.now() - 26 * 60 * 60 * 1000).toISOString();
saveCredentials({
accessToken: 'a'.repeat(60),
machineId: '1234567890abcdef1234567890abcdef',
authMethod: 'manual',
importedAt: expiredAt,
});
const result = await startDaemon({ port, ghost_mode: true });
expect(result.success).toBe(true);
try {
const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4.1',
messages: [{ role: 'user', content: 'hello' }],
}),
});
expect(response.status).toBe(401);
const body = (await response.json()) as { error?: { message?: string } };
expect(body.error?.message).toContain('expired');
} finally {
await stopDaemon();
}
});
it('validates invalid JSON, invalid message schema, and oversized body', async () => {
const port = 10000 + Math.floor(Math.random() * 50000);
const result = await startDaemon({ port, ghost_mode: true });
expect(result.success).toBe(true);
try {
const invalidJson = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{invalid-json',
});
expect(invalidJson.status).toBe(400);
const invalidSchema = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4.1',
messages: { role: 'user', content: 'hello' },
}),
});
expect(invalidSchema.status).toBe(400);
const oversized = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4.1',
messages: [
{
role: 'user',
content: 'x'.repeat(10 * 1024 * 1024 + 1024),
},
],
}),
});
expect(oversized.status).toBe(413);
} finally {
await stopDaemon();
}
});
});
describe('isDaemonRunning', () => {
+13
View File
@@ -57,6 +57,19 @@ describe('resolveCursorRequestModel', () => {
expect(resolved).toBe('claude-4.6-opus');
});
it('maps Anthropic family-first aliases to the matching Cursor model id', () => {
const resolved = resolveCursorRequestModel('claude-sonnet-4.5', DEFAULT_CURSOR_MODELS);
expect(resolved).toBe('claude-4.5-sonnet');
});
it('strips provider, dated, and thinking suffixes before resolving Anthropic aliases', () => {
const resolved = resolveCursorRequestModel(
'anthropic/claude-sonnet-4.5-20250929-thinking',
DEFAULT_CURSOR_MODELS
);
expect(resolved).toBe('claude-4.5-sonnet');
});
it('falls back to default when requested model is unavailable', () => {
const resolved = resolveCursorRequestModel('non-existent-model', DEFAULT_CURSOR_MODELS);
expect(resolved).toBe(DEFAULT_CURSOR_MODEL);
+9 -206
View File
@@ -1,214 +1,17 @@
#!/usr/bin/env node
'use strict';
/**
* Test Script: Multi-message thinking block behavior
*
* Simulates 3 consecutive messages to test if thinking blocks
* appear in all messages or only the first one.
*
* Usage: CCS_DEBUG=1 node test-thinking-multi-message.js
*/
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const ccsPath = path.join(__dirname, 'bin', 'ccs.js');
const logDir = path.join(require('os').homedir(), '.ccs', 'logs');
// Ensure logs directory exists
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
console.log('='.repeat(60));
console.log('GLMT Multi-Message Thinking Block Test');
console.log('Legacy GLMT Multi-Message Probe');
console.log('='.repeat(60));
console.log('');
console.log('Test scenario: 3 consecutive messages with thinking enabled');
console.log('Expected: Thinking blocks appear in ALL 3 messages');
console.log('Actual: User reports thinking only in first message');
console.log('This manual script targeted the retired `ccs glmt` runtime path.');
console.log('CCS now routes supported Z.AI usage through `ccs glm`, and thinking is');
console.log('handled natively by current upstream models instead of the old GLMT proxy.');
console.log('');
console.log('Log directory:', logDir);
console.log('Use one of these instead:');
console.log(' 1. tests/integration/glmt-integration-test.sh');
console.log(' 2. ccs glm "<prompt>"');
console.log(' 3. internal transformer tests under tests/unit/glmt/');
console.log('');
// Test messages
const messages = [
'Message 1: Calculate 15! (factorial)',
'Message 2: What is the square root of 2 to 10 decimal places?',
'Message 3: Explain the Pythagorean theorem'
];
// Track results
const results = {
message1: { thinking: false, error: null },
message2: { thinking: false, error: null },
message3: { thinking: false, error: null }
};
async function runMessage(messageIndex) {
const message = messages[messageIndex];
const messageKey = `message${messageIndex + 1}`;
console.log('-'.repeat(60));
console.log(`Testing Message ${messageIndex + 1}/${messages.length}`);
console.log(`Prompt: "${message}"`);
console.log('-'.repeat(60));
return new Promise((resolve, reject) => {
const startTime = Date.now();
// Clear old logs for this test
const beforeFiles = fs.readdirSync(logDir).filter(f => f.endsWith('.json'));
// Use process.execPath for Windows compatibility (CVE-2024-27980)
const child = spawn(process.execPath, [ccsPath, 'glmt', '--verbose', message], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
CCS_DEBUG: '1'
}
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
const text = data.toString();
stdout += text;
// Check for thinking indicator
if (text.includes('∴ Thinking') || text.includes('Thinking…')) {
results[messageKey].thinking = true;
console.log('[✓] Thinking block detected in stdout');
}
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
const duration = Date.now() - startTime;
console.log('');
console.log(`Process exited with code ${code} after ${duration}ms`);
// Check logs
const afterFiles = fs.readdirSync(logDir).filter(f => f.endsWith('.json'));
const newFiles = afterFiles.filter(f => !beforeFiles.includes(f));
console.log(`New log files: ${newFiles.length}`);
// Check for reasoning_content in response logs
const responseFiles = newFiles.filter(f => f.includes('response-openai'));
console.log(`Response log files: ${responseFiles.length}`);
if (responseFiles.length > 0) {
const latestResponse = responseFiles.sort().pop();
const responsePath = path.join(logDir, latestResponse);
console.log(`Latest response log: ${latestResponse}`);
try {
const responseData = JSON.parse(fs.readFileSync(responsePath, 'utf8'));
const reasoningContent = responseData.choices?.[0]?.message?.reasoning_content;
if (reasoningContent) {
const length = reasoningContent.length;
const lines = reasoningContent.split('\n').length;
console.log(`[✓] reasoning_content found: ${length} chars, ${lines} lines`);
results[messageKey].thinking = true;
} else {
console.log('[X] No reasoning_content in response');
results[messageKey].thinking = false;
}
} catch (e) {
console.log(`[!] Error reading response log: ${e.message}`);
results[messageKey].error = e.message;
}
} else {
console.log('[X] No response logs found');
results[messageKey].error = 'No response logs';
}
console.log('');
if (code === 0) {
resolve();
} else {
results[messageKey].error = `Exit code ${code}`;
reject(new Error(`Process exited with code ${code}`));
}
});
child.on('error', (error) => {
console.error(`[X] Process error: ${error.message}`);
results[messageKey].error = error.message;
reject(error);
});
});
}
async function main() {
try {
// Run messages sequentially
for (let i = 0; i < messages.length; i++) {
await runMessage(i);
// Wait a bit between messages
if (i < messages.length - 1) {
console.log('Waiting 2s before next message...');
console.log('');
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
// Final summary
console.log('='.repeat(60));
console.log('TEST RESULTS');
console.log('='.repeat(60));
console.log('');
for (let i = 1; i <= 3; i++) {
const key = `message${i}`;
const result = results[key];
const status = result.thinking ? '[✓ PASS]' : '[X FAIL]';
console.log(`${status} Message ${i}: Thinking = ${result.thinking}`);
if (result.error) {
console.log(` Error: ${result.error}`);
}
}
console.log('');
const passCount = Object.values(results).filter(r => r.thinking).length;
const failCount = 3 - passCount;
console.log(`Summary: ${passCount}/3 messages showed thinking blocks`);
console.log('');
if (failCount > 0) {
console.log('[!] ISSUE CONFIRMED: Some messages missing thinking blocks');
console.log('');
console.log('Next steps:');
console.log(' 1. Analyze request logs to verify reasoning params');
console.log(' 2. Check if transformer is being called correctly');
console.log(' 3. Verify state management (accumulator/parser)');
console.log('');
process.exit(1);
} else {
console.log('[✓] ALL TESTS PASSED: Thinking blocks appear in all messages');
console.log('');
process.exit(0);
}
} catch (error) {
console.error('');
console.error('[X] Test failed:', error.message);
console.error('');
process.exit(1);
}
}
main();
process.exit(0);
+129 -4
View File
@@ -7,14 +7,35 @@ import SharedManager from '../../src/management/shared-manager';
describe('InstanceManager MCP sync', () => {
let tempRoot = '';
let originalHome: string | undefined;
let originalCcsHome: string | undefined;
let originalCcsDir: string | undefined;
function writeMarketplaceRegistry(registryPath: string, installLocation: string): void {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(
registryPath,
JSON.stringify(
{
'claude-code-plugins': {
installLocation,
},
},
null,
2
),
'utf8'
);
}
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-'));
originalHome = process.env.HOME;
originalCcsHome = process.env.CCS_HOME;
originalCcsDir = process.env.CCS_DIR;
spyOn(os, 'homedir').mockReturnValue(tempRoot);
process.env.HOME = tempRoot;
process.env.CCS_HOME = tempRoot;
delete process.env.CCS_DIR;
});
@@ -22,6 +43,9 @@ describe('InstanceManager MCP sync', () => {
afterEach(() => {
mock.restore();
if (originalHome !== undefined) process.env.HOME = originalHome;
else delete process.env.HOME;
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
@@ -71,7 +95,9 @@ describe('InstanceManager MCP sync', () => {
const synced = manager.syncMcpServers(instancePath);
expect(synced).toBe(true);
const instanceContent = JSON.parse(fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8'));
const instanceContent = JSON.parse(
fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8')
);
expect(instanceContent.otherKey).toBe('keep-me');
expect(instanceContent.mcpServers).toEqual({
globalOnly: { command: 'global-cmd' },
@@ -96,9 +122,10 @@ describe('InstanceManager MCP sync', () => {
});
it('skips shared symlinks and MCP sync for bare instance creation', async () => {
const linkSharedSpy = spyOn(SharedManager.prototype, 'linkSharedDirectories').mockImplementation(
() => {}
);
const linkSharedSpy = spyOn(
SharedManager.prototype,
'linkSharedDirectories'
).mockImplementation(() => {});
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
@@ -106,9 +133,107 @@ describe('InstanceManager MCP sync', () => {
);
const manager = new InstanceManager();
const instancePath = manager.getInstancePath('sandbox');
const sharedRegistryPath = path.join(tempRoot, '.claude', 'plugins', 'known_marketplaces.json');
writeMarketplaceRegistry(
sharedRegistryPath,
path.join(
tempRoot,
'.ccs',
'instances',
'work',
'plugins',
'marketplaces',
'claude-code-plugins'
)
);
await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true });
const normalized = JSON.parse(fs.readFileSync(sharedRegistryPath, 'utf8'));
expect(linkSharedSpy).not.toHaveBeenCalled();
expect(fs.existsSync(instancePath)).toBe(true);
expect(normalized['claude-code-plugins'].installLocation).toBe(
path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins')
);
expect(fs.existsSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'))).toBe(
false
);
expect(syncMcpSpy).not.toHaveBeenCalled();
});
it('normalizes shared plugin metadata for existing non-bare instances', async () => {
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
() => false
);
const manager = new InstanceManager();
const instancePath = manager.getInstancePath('work');
writeMarketplaceRegistry(
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
path.join(
tempRoot,
'.ccs',
'instances',
'work',
'plugins',
'marketplaces',
'claude-code-plugins'
)
);
await manager.ensureInstance('work', { mode: 'isolated' });
const normalized = JSON.parse(
fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8')
);
expect(normalized['claude-code-plugins'].installLocation).toBe(
path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins')
);
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
});
it('normalizes shared plugin metadata during new non-bare instance creation', async () => {
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
() => false
);
const registryPath = path.join(tempRoot, '.claude', 'plugins', 'known_marketplaces.json');
writeMarketplaceRegistry(
registryPath,
path.join(
tempRoot,
'.ccs',
'instances',
'work',
'plugins',
'marketplaces',
'claude-code-plugins'
)
);
const manager = new InstanceManager();
const instancePath = await manager.ensureInstance('work', { mode: 'isolated' });
const expected = path.join(
tempRoot,
'.claude',
'plugins',
'marketplaces',
'claude-code-plugins'
);
const normalizedShared = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
const normalizedInstance = JSON.parse(
fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8')
);
expect(normalizedShared['claude-code-plugins'].installLocation).toBe(expected);
expect(normalizedInstance['claude-code-plugins'].installLocation).toBe(expected);
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
});
});
+171 -4
View File
@@ -1,17 +1,60 @@
/**
* Unit tests for SharedManager - plugin registry path normalization
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import SharedManager, {
normalizePluginMetadataPathString,
} from '../../src/management/shared-manager';
// Test the normalization regex pattern directly
const normalizePluginPaths = (content: string): string => {
return content.replace(/\/\.ccs\/instances\/[^/]+\//g, '/.claude/');
return normalizePluginMetadataPathString(content);
};
describe('SharedManager', () => {
let tempRoot = '';
let originalHome: string | undefined;
let originalCcsHome: string | undefined;
let originalCcsDir: string | undefined;
let originalPlatform: PropertyDescriptor | undefined;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-manager-test-'));
originalHome = process.env.HOME;
originalCcsHome = process.env.CCS_HOME;
originalCcsDir = process.env.CCS_DIR;
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
spyOn(os, 'homedir').mockReturnValue(tempRoot);
process.env.HOME = tempRoot;
process.env.CCS_HOME = tempRoot;
delete process.env.CCS_DIR;
});
afterEach(() => {
mock.restore();
if (originalHome !== undefined) process.env.HOME = originalHome;
else delete process.env.HOME;
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir;
else delete process.env.CCS_DIR;
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
if (tempRoot && fs.existsSync(tempRoot)) {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
describe('normalizePluginRegistryPaths', () => {
describe('regex pattern', () => {
it('should replace instance paths with canonical claude path', () => {
@@ -82,6 +125,24 @@ describe('SharedManager', () => {
'/home/kai/.claude/plugins/cache/claude-hud/claude-hud/0.0.2'
);
});
it('should normalize marketplace installLocation values', () => {
const original = {
'claude-code-plugins': {
installLocation:
'/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins',
},
};
const input = JSON.stringify(original, null, 2);
const result = normalizePluginPaths(input);
expect(() => JSON.parse(result)).not.toThrow();
const parsed = JSON.parse(result);
expect(parsed['claude-code-plugins'].installLocation).toBe(
'/home/kai/.claude/plugins/marketplaces/claude-code-plugins'
);
});
});
describe('edge cases', () => {
@@ -96,10 +157,116 @@ describe('SharedManager', () => {
});
it('should handle Windows-style paths (backslash)', () => {
// Windows paths use backslashes, regex should not match
const input = 'C:\\Users\\user\\.ccs\\instances\\ck\\plugins\\cache';
expect(normalizePluginPaths(input)).toBe(input);
expect(normalizePluginPaths(input)).toBe('C:\\Users\\user\\.claude\\plugins\\cache');
});
});
});
describe('normalizeMarketplaceRegistryPaths', () => {
it('rewrites known_marketplaces.json on disk', () => {
const pluginsDir = path.join(tempRoot, '.claude', 'plugins');
fs.mkdirSync(pluginsDir, { recursive: true });
const registryPath = path.join(pluginsDir, 'known_marketplaces.json');
fs.writeFileSync(
registryPath,
JSON.stringify(
{
'claude-code-plugins': {
installLocation:
'/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins',
},
},
null,
2
),
'utf8'
);
const manager = new SharedManager();
manager.normalizeMarketplaceRegistryPaths();
const normalized = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
expect(normalized['claude-code-plugins'].installLocation).toBe(
'/home/kai/.claude/plugins/marketplaces/claude-code-plugins'
);
});
it('rewrites Windows-style known_marketplaces.json paths on disk', () => {
const pluginsDir = path.join(tempRoot, '.claude', 'plugins');
fs.mkdirSync(pluginsDir, { recursive: true });
const registryPath = path.join(pluginsDir, 'known_marketplaces.json');
fs.writeFileSync(
registryPath,
JSON.stringify(
{
'claude-code-plugins': {
installLocation:
'C:\\Users\\kai\\.ccs\\instances\\work\\plugins\\marketplaces\\claude-code-plugins',
},
},
null,
2
),
'utf8'
);
const manager = new SharedManager();
manager.normalizeMarketplaceRegistryPaths();
const normalized = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
expect(normalized['claude-code-plugins'].installLocation).toBe(
'C:\\Users\\kai\\.claude\\plugins\\marketplaces\\claude-code-plugins'
);
});
it('normalizes copied shared and instance metadata under Windows fallback', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
spyOn(fs, 'symlinkSync').mockImplementation(() => {
throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' });
});
const pluginsDir = path.join(tempRoot, '.claude', 'plugins');
fs.mkdirSync(pluginsDir, { recursive: true });
const registryPath = path.join(pluginsDir, 'known_marketplaces.json');
fs.writeFileSync(
registryPath,
JSON.stringify(
{
'claude-code-plugins': {
installLocation:
'/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins',
},
},
null,
2
),
'utf8'
);
const manager = new SharedManager();
const instancePath = path.join(tempRoot, '.ccs', 'instances', 'personal');
fs.mkdirSync(instancePath, { recursive: true });
manager.linkSharedDirectories(instancePath);
const expected = '/home/kai/.claude/plugins/marketplaces/claude-code-plugins';
const claudeRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
const sharedRegistry = JSON.parse(
fs.readFileSync(
path.join(tempRoot, '.ccs', 'shared', 'plugins', 'known_marketplaces.json'),
'utf8'
)
);
const instanceRegistry = JSON.parse(
fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8')
);
expect(claudeRegistry['claude-code-plugins'].installLocation).toBe(expected);
expect(sharedRegistry['claude-code-plugins'].installLocation).toBe(expected);
expect(instanceRegistry['claude-code-plugins'].installLocation).toBe(expected);
});
});
});
@@ -1,4 +1,14 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
mock,
spyOn,
} from 'bun:test';
import { EventEmitter } from 'events';
import * as childProcess from 'child_process';
import * as fs from 'fs';
@@ -115,6 +125,7 @@ preferences:
let execClaude: typeof import('../../../src/utils/shell-executor').execClaude;
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor;
let SharedManager: typeof import('../../../src/management/shared-manager').default;
beforeAll(async () => {
registerChildProcessMock();
@@ -123,6 +134,9 @@ beforeAll(async () => {
execClaude = shellExecutor.execClaude;
stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv;
const sharedManagerModule = await import('../../../src/management/shared-manager');
SharedManager = sharedManagerModule.default;
const headless = await import('../../../src/delegation/headless-executor');
HeadlessExecutor = headless.HeadlessExecutor;
});
@@ -242,6 +256,32 @@ describe('CLAUDECODE environment stripping', () => {
expect(env.DISABLE_AUTOUPDATER).toBeUndefined();
});
it('execClaude normalizes shared plugin metadata before default-profile launch', () => {
const normalizeSpy = spyOn(
SharedManager.prototype,
'normalizeSharedPluginMetadataPaths'
).mockImplementation(() => {});
execClaude('claude', ['--help'], { CCS_PROFILE_TYPE: 'default' });
expect(normalizeSpy).toHaveBeenCalledWith(undefined);
});
it('execClaude normalizes shared plugin metadata using CLAUDE_CONFIG_DIR when provided', () => {
const normalizeSpy = spyOn(
SharedManager.prototype,
'normalizeSharedPluginMetadataPaths'
).mockImplementation(() => {});
const instancePath = path.join(os.tmpdir(), 'ccs-shell-executor-instance');
execClaude('claude', ['--help'], {
CCS_PROFILE_TYPE: 'settings',
CLAUDE_CONFIG_DIR: instancePath,
});
expect(normalizeSpy).toHaveBeenCalledWith(instancePath);
});
it('headless executor spawn path strips CLAUDECODE before spawn', async () => {
writeConfigWithAutoUpdatePreference(false);
process.env.CLAUDECODE = 'nested';
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'bun:test';
import {
buildGlmtCompatibilityWarnings,
isDeprecatedGlmtProfileName,
isLegacyGlmtBaseUrl,
normalizeDeprecatedGlmtEnv,
} from '../../../src/utils/glmt-deprecation';
describe('glmt deprecation helpers', () => {
it('detects the deprecated glmt profile name case-insensitively', () => {
expect(isDeprecatedGlmtProfileName('glmt')).toBe(true);
expect(isDeprecatedGlmtProfileName('GLMT')).toBe(true);
expect(isDeprecatedGlmtProfileName('glm')).toBe(false);
});
it('detects legacy GLMT proxy base URLs', () => {
expect(isLegacyGlmtBaseUrl('https://api.z.ai/api/coding/paas/v4/chat/completions')).toBe(
true
);
expect(isLegacyGlmtBaseUrl('https://api.z.ai/api/coding/paas/v4/chat/completions/')).toBe(
true
);
expect(isLegacyGlmtBaseUrl('https://api.z.ai/api/anthropic')).toBe(false);
});
it('normalizes legacy GLMT proxy settings to the direct GLM endpoint', () => {
const result = normalizeDeprecatedGlmtEnv({
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/coding/paas/v4/chat/completions',
ANTHROPIC_AUTH_TOKEN: 'ghp_test',
ANTHROPIC_MODEL: 'glm-5',
ENABLE_STREAMING: 'true',
MAX_THINKING_TOKENS: '32768',
API_TIMEOUT_MS: '3000000',
});
expect(result.migrated).toBe(true);
expect(result.env['ANTHROPIC_BASE_URL']).toBe('https://api.z.ai/api/anthropic');
expect(result.env['ENABLE_STREAMING']).toBeUndefined();
expect(result.env['MAX_THINKING_TOKENS']).toBeUndefined();
expect(result.env['API_TIMEOUT_MS']).toBeUndefined();
expect(result.warnings).toContain(
'CCS normalized legacy GLMT proxy settings to the direct GLM endpoint for this run.'
);
});
it('keeps already-direct GLM settings intact apart from deprecation messaging', () => {
const result = normalizeDeprecatedGlmtEnv({
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
ANTHROPIC_AUTH_TOKEN: 'ghp_test',
ANTHROPIC_MODEL: 'glm-5',
});
expect(result.migrated).toBe(false);
expect(result.env['ANTHROPIC_BASE_URL']).toBe('https://api.z.ai/api/anthropic');
expect(result.warnings).toEqual(buildGlmtCompatibilityWarnings(false));
});
});
@@ -1,10 +1,21 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
mock,
spyOn,
} from 'bun:test';
import express from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Server } from 'http';
import claudeExtensionRoutes from '../../../src/web-server/routes/claude-extension-routes';
import SharedManager from '../../../src/management/shared-manager';
import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types';
import { saveUnifiedConfig } from '../../../src/config/unified-config-loader';
@@ -98,6 +109,8 @@ describe('web-server claude-extension-routes', () => {
});
afterEach(() => {
mock.restore();
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
@@ -143,6 +156,61 @@ describe('web-server claude-extension-routes', () => {
expect(payload.sharedSettings.json).toContain('"env"');
});
it('normalizes the effective profile CLAUDE_CONFIG_DIR for extension setup', async () => {
const explicitConfigDir = path.join(tempHome, '.claude-profiles', 'glm');
const glmSettingsPath = path.join(tempHome, '.ccs', 'glm.settings.json');
const normalizeSpy = spyOn(
SharedManager.prototype,
'normalizeSharedPluginMetadataPaths'
).mockImplementation(() => {});
fs.writeFileSync(
glmSettingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.example.test',
ANTHROPIC_API_KEY: 'sk-ant-test-123456',
ANTHROPIC_MODEL: 'claude-sonnet-4-5',
CLAUDE_CONFIG_DIR: explicitConfigDir,
},
},
null,
2
) + '\n'
);
const config = createEmptyUnifiedConfig();
config.profiles.glm = {
type: 'api',
settings: glmSettingsPath,
};
config.accounts.work = {
created: '2026-03-15T00:00:00.000Z',
last_used: null,
context_mode: 'isolated',
};
config.default = 'work';
config.continuity = {
inherit_from_account: {
glm: 'work',
},
};
saveUnifiedConfig(config);
const response = await fetch(`${baseUrl}/api/claude-extension/setup?profile=glm&host=vscode`);
expect(response.status).toBe(200);
const payload = (await response.json()) as {
ideSettings: { json: string };
};
expect(payload.ideSettings.json).toContain(explicitConfigDir);
expect(normalizeSpy.mock.calls.some(([configDir]) => configDir === explicitConfigDir)).toBe(
true
);
});
it('renders Windsurf setup for default account resolution via CLAUDE_CONFIG_DIR', async () => {
const response = await fetch(
`${baseUrl}/api/claude-extension/setup?profile=default&host=windsurf`
@@ -179,7 +247,11 @@ describe('web-server claude-extension-routes', () => {
expect(createResponse.status).toBe(201);
const created = (await createResponse.json()) as {
binding: { id: string; effectiveIdeSettingsPath: string; usesDefaultIdeSettingsPath: boolean };
binding: {
id: string;
effectiveIdeSettingsPath: string;
usesDefaultIdeSettingsPath: boolean;
};
};
expect(created.binding.effectiveIdeSettingsPath).toBe(ideSettingsPath);
expect(created.binding.usesDefaultIdeSettingsPath).toBe(false);
@@ -418,7 +490,10 @@ describe('web-server claude-extension-routes', () => {
);
expect(applyResponse.status).toBe(200);
let ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<string, unknown>;
let ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<
string,
unknown
>;
const appliedEnv = ideSettings['claudeCode.environmentVariables'] as Array<{
name: string;
value: string;
@@ -426,7 +501,9 @@ describe('web-server claude-extension-routes', () => {
expect(appliedEnv.some((entry) => entry.name === 'KEEP_ME' && entry.value === '1')).toBe(true);
expect(
appliedEnv.some((entry) => entry.name === 'ANTHROPIC_API_KEY' && entry.value === 'sk-ant-test-123456')
appliedEnv.some(
(entry) => entry.name === 'ANTHROPIC_API_KEY' && entry.value === 'sk-ant-test-123456'
)
).toBe(true);
const verifyAppliedResponse = await fetch(
@@ -452,7 +529,9 @@ describe('web-server claude-extension-routes', () => {
ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<string, unknown>;
expect(ideSettings['editor.tabSize']).toBe(2);
expect(ideSettings['claudeCode.disableLoginPrompt']).toBeUndefined();
expect(ideSettings['claudeCode.environmentVariables']).toEqual([{ name: 'KEEP_ME', value: '1' }]);
expect(ideSettings['claudeCode.environmentVariables']).toEqual([
{ name: 'KEEP_ME', value: '1' },
]);
const verifyResetResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify`
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it } from 'bun:test';
import type { AddressInfo } from 'net';
import { startServer } from '../../../src/web-server';
const instances: Array<Awaited<ReturnType<typeof startServer>>> = [];
afterEach(async () => {
while (instances.length > 0) {
const instance = instances.pop();
if (!instance) {
continue;
}
instance.cleanup();
await new Promise<void>((resolve) => instance.server.close(() => resolve()));
}
});
describe('startServer host binding', () => {
it('binds with system-default host when no host is provided', async () => {
const instance = await startServer({ port: 0 });
instances.push(instance);
const address = instance.server.address() as AddressInfo;
expect(address.port).toBeGreaterThan(0);
});
it('binds to an explicit loopback host', async () => {
const instance = await startServer({ port: 0, host: '127.0.0.1' });
instances.push(instance);
const address = instance.server.address() as AddressInfo;
expect(address.address).toBe('127.0.0.1');
});
it('binds to wildcard host when requested', async () => {
const instance = await startServer({ port: 0, host: '0.0.0.0' });
instances.push(instance);
const address = instance.server.address() as AddressInfo;
expect(['0.0.0.0', '::']).toContain(address.address);
});
});

Some files were not shown because too many files have changed in this diff Show More