mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 12:21:20 +00:00
Merge pull request #754 from kaitranntt/dev
feat(release): promote dev to main
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -11,7 +11,7 @@ Run Claude, Gemini, GLM, and any Anthropic-compatible API - concurrently, withou
|
||||
[](https://www.npmjs.com/package/@kaitranntt/ccs)
|
||||
[](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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)'
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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".`
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1031
File diff suppressed because it is too large
Load Diff
@@ -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('');
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('# ----------------------------------------------------------------------------');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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: ')) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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}`));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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
@@ -32,7 +32,7 @@ export type {
|
||||
DelegationEvent,
|
||||
} from './delegation';
|
||||
|
||||
// GLMT types
|
||||
// Legacy GLMT transformer types
|
||||
export type {
|
||||
AnthropicMessage,
|
||||
ContentBlock,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -458,3 +458,4 @@ router.delete('/:name', (req: Request, res: Response): void => {
|
||||
});
|
||||
|
||||
export default router;
|
||||
export { parseTarget } from './route-helpers';
|
||||
|
||||
@@ -295,3 +295,4 @@ router.delete('/:name', (req: Request, res: Response): void => {
|
||||
});
|
||||
|
||||
export default router;
|
||||
export { parseTarget } from './route-helpers';
|
||||
|
||||
+15
-13
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user