diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..37b1b341 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -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. diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 00000000..24edae15 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..312d0210 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 00000000..1b4b97aa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 00000000..57c89761 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -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. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..3ed3a780 --- /dev/null +++ b/.github/pull_request_template.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 072e66bf..41b8334e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e45bcb1d..e469fbdd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 - -``` -/ - -# 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 - -# When done developing: -bun run dev:unlink # Restores original global ccs - -# Run tests -# Test with: ccs - -# 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. \ No newline at end of file +- 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) diff --git a/README.md b/README.md index c059aac6..815aa9fb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Run Claude, Gemini, GLM, and any Anthropic-compatible API - concurrently, withou [![npm](https://img.shields.io/npm/v/@kaitranntt/ccs?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@kaitranntt/ccs) [![PoweredBy](https://img.shields.io/badge/PoweredBy-ClaudeKit-C15F3C?style=for-the-badge)](https://claudekit.cc?ref=HMNKXOHN) -**[Features & Pricing](https://ccs.kaitran.ca)** | **[Documentation](https://docs.ccs.kaitran.ca)** +**[Features & Pricing](https://ccs.kaitran.ca)** | **[Documentation Hub](https://docs.ccs.kaitran.ca)** @@ -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:
-## 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) diff --git a/config/base-codex.settings.json b/config/base-codex.settings.json index 390d568d..5dae2d86 100644 --- a/config/base-codex.settings.json +++ b/config/base-codex.settings.json @@ -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" } } diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index ae78f67a..df29f733 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -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 | diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index 1aa32cda..8278544e 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -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 diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index 1449e0ec..df381442 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -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. diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 8a721c93..347851b2 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -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 diff --git a/docs/system-architecture/index.md b/docs/system-architecture/index.md index 02b3d450..a13cd68e 100644 --- a/docs/system-architecture/index.md +++ b/docs/system-architecture/index.md @@ -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 diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index 0663f768..8ccec44c 100644 --- a/docs/system-architecture/provider-flows.md +++ b/docs/system-architecture/provider-flows.md @@ -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 } ``` diff --git a/package.json b/package.json index 28673198..0c528ece 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/completion/README.md b/scripts/completion/README.md index 103316c8..d70103e0 100644 --- a/scripts/completion/README.md +++ b/scripts/completion/README.md @@ -116,7 +116,7 @@ sudo cp scripts/completion/ccs.fish /usr/share/fish/vendor_completions.d/ ```bash $ ccs -auth doctor glm glmt kimi work personal --help --version +auth doctor glm kimi work personal --help --version $ ccs auth create list show remove default --help diff --git a/scripts/completion/ccs.fish b/scripts/completion/ccs.fish index ea58c2d7..107607b9 100644 --- a/scripts/completion/ccs.fish +++ b/scripts/completion/ccs.fish @@ -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 diff --git a/scripts/completion/ccs.zsh b/scripts/completion/ccs.zsh index fb4f9f36..9c948b6a 100644 --- a/scripts/completion/ccs.zsh +++ b/scripts/completion/ccs.zsh @@ -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)' ) diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 46c6bbf0..376db755 100755 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -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 diff --git a/src/ccs.ts b/src/ccs.ts index 704ac9bb..5e412bef 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -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 { - // 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 { 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 = { - '--version': 'version', - '-v': 'version', - '--help': 'help', - '-h': 'help', - '--doctor': 'doctor', - '--sync': 'sync', - '--cleanup': 'cleanup', - '--setup': 'setup', - }; - - const normalizedFirstArg = commandAliases[firstArg] || firstArg; - - const earlyCommandHandlers: Record Promise> = { - 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 { 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 { ); } 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 { } 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 { // 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 { } } - // 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 diff --git a/src/cliproxy/codex-plan-compatibility.ts b/src/cliproxy/codex-plan-compatibility.ts new file mode 100644 index 00000000..41177e00 --- /dev/null +++ b/src/cliproxy/codex-plan-compatibility.ts @@ -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([ + ['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 { + 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('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".` + ) + ); +} diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 0cb45f6d..9945ef4d 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -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; diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index a250bb2d..375a2b34 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -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'; diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 8194e640..d9644ed5 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -147,15 +147,59 @@ export const MODEL_CATALOG: Partial> = 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> = { 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, }, }, diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index 0400a54a..21c02ef6 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -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 { diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index 4dbca1fc..7c7d2038 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -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 diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index 535e2eb7..6aa43a58 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -1,1031 +1 @@ -/** - * API Command Handler - * - * Manages CCS API profiles for custom API providers. - * Commands: create, list, remove - * - * CLI parsing and output formatting only. - * Business logic delegated to src/api/services/. - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import { - initUI, - header, - subheader, - color, - dim, - ok, - fail, - warn, - info, - table, - infoBox, -} from '../utils/ui'; -import { InteractivePrompt } from '../utils/prompt'; -import { - validateApiName, - validateUrl, - getUrlWarning, - sanitizeBaseUrl, - apiProfileExists, - listApiProfiles, - createApiProfile, - removeApiProfile, - getApiProfileNames, - isUsingUnifiedConfig, - isOpenRouterUrl, - pickOpenRouterModel, - PROVIDER_PRESETS, - getPresetById, - getPresetAliases, - getPresetIds, - discoverApiProfileOrphans, - registerApiProfileOrphans, - copyApiProfile, - exportApiProfile, - importApiProfileBundle, - type ModelMapping, - type ProviderPreset, -} from '../api/services'; -import { syncToLocalConfig } from '../cliproxy/sync/local-config-sync'; -import type { TargetType } from '../targets/target-adapter'; -import { extractOption, hasAnyFlag } from './arg-extractor'; - -interface ApiCommandArgs { - name?: string; - baseUrl?: string; - apiKey?: string; - model?: string; - preset?: string; - target?: TargetType; - force?: boolean; - yes?: boolean; - errors: string[]; -} - -const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y'] as const; -const API_VALUE_FLAGS = ['--base-url', '--api-key', '--model', '--preset', '--target'] as const; -const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS]; -const API_VALUE_FLAG_SET = new Set(API_VALUE_FLAGS); - -function sanitizeHelpText(value: string): string { - return value - .replace(/[\r\n\t]+/g, ' ') - .replace(/[\x00-\x1f\x7f]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); -} - -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}`; -} - -function applyRepeatedOption( - args: string[], - flags: readonly string[], - onValue: (value: string) => void, - onMissing: () => void -): string[] { - let remaining = [...args]; - - while (true) { - const extracted = extractOption(remaining, flags, { - allowDashValue: true, - knownFlags: API_KNOWN_FLAGS, - }); - if (!extracted.found) { - return remaining; - } - - if (extracted.missingValue || !extracted.value) { - onMissing(); - } else { - onValue(extracted.value); - } - - remaining = extracted.remainingArgs; - } -} - -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; -} - -function parseOptionalTargetFlag( - args: string[], - knownFlags: readonly string[] -): { target?: TargetType; remainingArgs: string[]; errors: string[] } { - const extracted = extractOption(args, ['--target'], { - allowDashValue: true, - 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: [] }; -} - -/** Parse command line arguments for api commands */ -export function parseApiCommandArgs(args: string[]): ApiCommandArgs { - const result: ApiCommandArgs = { - 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'); - } - ); - - remaining = applyRepeatedOption( - remaining, - ['--api-key'], - (value) => { - result.apiKey = value; - }, - () => { - result.errors.push('Missing value for --api-key'); - } - ); - - remaining = applyRepeatedOption( - remaining, - ['--model'], - (value) => { - result.model = value; - }, - () => { - result.errors.push('Missing value for --model'); - } - ); - - remaining = applyRepeatedOption( - remaining, - ['--preset'], - (value) => { - result.preset = value; - }, - () => { - result.errors.push('Missing value for --preset'); - } - ); - - 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'); - } - ); - - const positionalArgs = extractPositionalArgs(remaining); - result.name = positionalArgs[0]; - return result; -} - -/** Handle 'ccs api create' command */ -async function handleCreate(args: string[]): Promise { - await initUI(); - const parsedArgs = parseApiCommandArgs(args); - - if (parsedArgs.errors.length > 0) { - parsedArgs.errors.forEach((errorMessage) => { - console.log(fail(errorMessage)); - }); - process.exit(1); - } - - console.log(header('Create API Profile')); - console.log(''); - - // Handle --preset option for quick provider setup - const preset = parsedArgs.preset ? getPresetById(parsedArgs.preset) : null; - if (parsedArgs.preset && !preset) { - console.log(fail(`Unknown preset: ${parsedArgs.preset}`)); - console.log(''); - console.log('Available presets:'); - getPresetIds().forEach((id) => console.log(` - ${sanitizeHelpText(id)}`)); - process.exit(1); - } - - // Step 1: API name (use preset default if --preset provided) - let name = parsedArgs.name || preset?.defaultProfileName; - if (!name) { - name = await InteractivePrompt.input('API name', { - validate: validateApiName, - }); - } else { - const error = validateApiName(name); - if (error) { - console.log(fail(error)); - process.exit(1); - } - } - - // Check if exists - if (apiProfileExists(name) && !parsedArgs.force) { - console.log(fail(`API '${name}' already exists`)); - console.log(` Use ${color('--force', 'command')} to overwrite`); - process.exit(1); - } - - // Step 2: Base URL (use preset if provided; skip prompt for presets with empty baseUrl like anthropic) - let baseUrl = parsedArgs.baseUrl ?? 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) { - // Only validate custom URLs, not preset URLs - const error = validateUrl(baseUrl); - if (error) { - console.log(fail(error)); - process.exit(1); - } - } - - // Check for common URL mistakes and warn (skip for presets) - 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), - }); - } - } - } else { - // Show preset info - console.log(info(`Using preset: ${preset.name}`)); - console.log(dim(` ${preset.description}`)); - if (preset.baseUrl) { - console.log(dim(` Base URL: ${preset.baseUrl}`)); - } else { - console.log(dim(` Auth: Native Anthropic API (x-api-key header)`)); - } - console.log(''); - } - - // Auto-detect Anthropic direct API when user enters api.anthropic.com URL without 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 = ''; - } - - // OpenRouter detection: offer interactive model picker - let openRouterModel: string | undefined; - let openRouterTierMapping: { opus?: string; sonnet?: string; haiku?: string } | undefined; - - if (isOpenRouterUrl(baseUrl) && !parsedArgs.model) { - 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.')); - } - - // Step 3: API Key (skip if preset has requiresApiKey: false) - let apiKey = parsedArgs.apiKey; - - if (preset?.requiresApiKey === false) { - const presetLabel = preset.name; - const optionalApiKey = preset.apiKeyPlaceholder || preset.id; - - if (parsedArgs.apiKey) { - console.log(dim(`Note: Using provided API key for ${presetLabel} (optional)`)); - apiKey = parsedArgs.apiKey; - } else { - console.log(info(`No API key required for ${presetLabel}`)); - // Local providers still need a truthy auth token persisted in settings.json. - apiKey = optionalApiKey; - } - } else if (!apiKey) { - const keyPrompt = preset?.apiKeyHint ? `API Key (${preset.apiKeyHint})` : 'API Key'; - apiKey = await InteractivePrompt.password(keyPrompt); - if (!apiKey) { - console.log(fail('API key is required')); - process.exit(1); - } - } - - // Step 4: Model configuration (use preset default if available) - const defaultModel = preset?.defaultModel || 'claude-sonnet-4-6'; - let model = parsedArgs.model || openRouterModel || preset?.defaultModel; - if (!model && !parsedArgs.yes && !preset) { - model = await InteractivePrompt.input('Default model (ANTHROPIC_MODEL)', { - default: defaultModel, - }); - } - model = model || defaultModel; - - // Step 5: Model mapping for Opus/Sonnet/Haiku (skip prompt for presets with --yes) - let opusModel = openRouterTierMapping?.opus || model; - let sonnetModel = openRouterTierMapping?.sonnet || model; - let haikuModel = openRouterTierMapping?.haiku || model; - const isCustomModel = model !== defaultModel; - const hasOpenRouterTierMapping = openRouterTierMapping !== undefined; - const hasPreset = preset !== null; - - if (!parsedArgs.yes && !hasOpenRouterTierMapping && !hasPreset) { - let wantCustomMapping = isCustomModel; - - if (!isCustomModel) { - 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( - isCustomModel - ? 'Configure model IDs for each tier (defaults to your model):' - : 'Leave blank to use the default model for each.' - ) - ); - 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; - } - } - - const models: ModelMapping = { - default: model, - opus: opusModel, - sonnet: sonnetModel, - haiku: haikuModel, - }; - let resolvedTarget: TargetType = parsedArgs.target || 'claude'; - - if (!parsedArgs.target && !parsedArgs.yes) { - const useDroidByDefault = await InteractivePrompt.confirm( - 'Set default target to Factory Droid for this profile?', - { default: false } - ); - if (useDroidByDefault) { - resolvedTarget = 'droid'; - } - } - - // Create profile - console.log(''); - console.log(info('Creating API profile...')); - - const result = createApiProfile(name, baseUrl || '', apiKey, models, resolvedTarget); - - if (!result.success) { - console.log(fail(`Failed to create API profile: ${result.error}`)); - process.exit(1); - } - - // Trigger sync to local CLIProxy config (best-effort) - try { - syncToLocalConfig(); - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - console.log(`[i] Auto-sync to CLIProxy config skipped: ${message}`); - } - - // Display success - console.log(''); - const hasCustomMapping = opusModel !== model || sonnetModel !== model || haikuModel !== model; - let infoMsg = - `API: ${name}\n` + - `Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` + - `Settings: ${result.settingsFile}\n` + - `Base URL: ${baseUrl}\n` + - `Model: ${model}\n` + - `Target: ${resolvedTarget}`; - - if (hasCustomMapping) { - infoMsg += - `\n\nModel Mapping:\n` + - ` Opus: ${opusModel}\n` + - ` Sonnet: ${sonnetModel}\n` + - ` Haiku: ${haikuModel}`; - } - - console.log(infoBox(infoMsg, 'API Profile Created')); - console.log(''); - console.log(header('Usage')); - if (resolvedTarget === '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(''); -} - -/** Handle 'ccs api list' command */ -async function handleList(): Promise { - await initUI(); - - 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; - } - - // Build table data - const rows: string[][] = profiles.map((p) => { - const status = p.isConfigured ? color('[OK]', 'success') : color('[!]', 'warning'); - return [p.name, p.target, p.settingsPath, status]; - }); - - const colWidths = isUsingUnifiedConfig() ? [15, 10, 20, 10] : [15, 10, 35, 10]; - console.log( - table(rows, { - head: ['API', 'Target', isUsingUnifiedConfig() ? 'Config' : 'Settings File', 'Status'], - colWidths, - }) - ); - console.log(''); - - // Show CLIProxy variants if any - if (variants.length > 0) { - console.log(subheader('CLIProxy Variants')); - const cliproxyRows = variants.map((v) => [v.name, v.provider, v.target, v.settings]); - console.log( - table(cliproxyRows, { - head: ['Variant', 'Provider', 'Target', 'Settings'], - colWidths: [15, 12, 10, 28], - }) - ); - console.log(''); - } - - console.log(dim(`Total: ${profiles.length} API profile(s)`)); - console.log(''); -} - -/** Handle 'ccs api remove' command */ -async function handleRemove(args: string[]): Promise { - await initUI(); - const parsedArgs = parseApiCommandArgs(args); - - if (parsedArgs.errors.length > 0) { - parsedArgs.errors.forEach((errorMessage) => { - console.log(fail(errorMessage)); - }); - process.exit(1); - } - - const apis = getApiProfileNames(); - - if (apis.length === 0) { - console.log(warn('No API profiles to remove')); - process.exit(0); - } - - // Interactive API selection if not provided - let name = parsedArgs.name; - if (!name) { - console.log(header('Remove API Profile')); - console.log(''); - console.log('Available APIs:'); - apis.forEach((p, i) => console.log(` ${i + 1}. ${p}`)); - console.log(''); - - name = await InteractivePrompt.input('API name to remove', { - validate: (val) => { - if (!val) return 'API name is required'; - if (!apis.includes(val)) return `API '${val}' not found`; - return null; - }, - }); - } - - if (!apis.includes(name)) { - console.log(fail(`API '${name}' not found`)); - console.log(''); - console.log('Available APIs:'); - apis.forEach((p) => console.log(` - ${p}`)); - process.exit(1); - } - - // Confirm deletion - 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(''); -} - -/** Handle 'ccs api discover' command */ -async function handleDiscover(args: string[]): Promise { - 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 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; - } - - const rows = 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]; - }); - - console.log( - table(rows, { - 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(''); -} - -/** Handle 'ccs api copy' command */ -async function handleCopy(args: string[]): Promise { - await initUI(); - const parsedArgs = parseApiCommandArgs(args); - if (parsedArgs.errors.length > 0) { - parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage))); - process.exit(1); - } - - const positionals = extractPositionalArgs(args); - const source = positionals[0]; - let destination = positionals[1]; - - if (!source) { - console.log(fail('Source profile is required. Usage: ccs api copy ')); - 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}`)); - if (result.warnings && result.warnings.length > 0) { - result.warnings.forEach((warningMessage) => console.log(warn(warningMessage))); - } - console.log(''); -} - -/** Handle 'ccs api export' command */ -async function handleExport(args: string[]): Promise { - await initUI(); - const includeSecrets = hasAnyFlag(args, ['--include-secrets']); - - const outExtracted = extractOption(args, ['--out'], { - allowDashValue: true, - knownFlags: [...API_KNOWN_FLAGS, '--out', '--include-secrets'], - }); - if (outExtracted.found && (outExtracted.missingValue || !outExtracted.value)) { - console.log(fail('Missing value for --out')); - process.exit(1); - } - const outPath = outExtracted.value; - const positionals = extractPositionalArgs(outExtracted.remainingArgs); - const name = positionals[0]; - - if (!name) { - console.log(fail('Profile name is required. Usage: ccs api export [--out ]')); - 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 resolvedOutputPath = path.resolve(outPath || `${name}.ccs-profile.json`); - fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); - fs.writeFileSync(resolvedOutputPath, JSON.stringify(result.bundle, null, 2) + '\n', 'utf8'); - - console.log(ok(`Profile exported to: ${resolvedOutputPath}`)); - if (result.redacted) { - console.log(warn('Token was redacted in export. Use --include-secrets to include it.')); - } - console.log(''); -} - -/** Handle 'ccs api import' command */ -async function handleImport(args: string[]): Promise { - await initUI(); - const force = hasAnyFlag(args, ['--force']); - const yes = hasAnyFlag(args, ['--yes', '-y']); - - const nameExtracted = extractOption(args, ['--name'], { - allowDashValue: true, - knownFlags: [...API_KNOWN_FLAGS, '--name'], - }); - if (nameExtracted.found && (nameExtracted.missingValue || !nameExtracted.value)) { - console.log(fail('Missing value for --name')); - process.exit(1); - } - - const targetParsed = parseOptionalTargetFlag(nameExtracted.remainingArgs, [ - ...API_KNOWN_FLAGS, - '--name', - ]); - if (targetParsed.errors.length > 0) { - targetParsed.errors.forEach((errorMessage) => console.log(fail(errorMessage))); - process.exit(1); - } - - const positionals = extractPositionalArgs(targetParsed.remainingArgs); - const importPath = positionals[0]; - if (!importPath) { - console.log( - fail('Import file path is required. Usage: ccs api import [--name ]') - ); - process.exit(1); - } - - if (!fs.existsSync(importPath)) { - console.log(fail(`File not found: ${importPath}`)); - process.exit(1); - } - - const raw = fs.readFileSync(importPath, 'utf8'); - let bundle: unknown; - try { - bundle = JSON.parse(raw); - } 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((issue) => { - const indicator = issue.level === 'error' ? color('[X]', 'error') : color('[!]', 'warning'); - console.log(`${indicator} ${issue.message}`); - }); - } - process.exit(1); - } - - console.log(ok(`Profile imported: ${result.name}`)); - if (result.warnings && result.warnings.length > 0) { - result.warnings.forEach((warningMessage) => console.log(warn(warningMessage))); - } - console.log(''); -} - -/** Show help for api commands */ -async function showHelp(): Promise { - 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')} [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 ', 'command')} Duplicate API profile settings + config`); - console.log( - ` ${color('export ', 'command')} Export profile bundle for cross-device transfer` - ); - console.log( - ` ${color('import ', 'command')} Import profile bundle and register profile` - ); - console.log(` ${color('remove ', 'command')} Remove an API profile`); - console.log(''); - console.log(subheader('Options')); - console.log( - ` ${color('--preset ', 'command')} Use provider preset (${presetIds.join(', ')})` - ); - console.log(` ${color('--base-url ', 'command')} API base URL (create)`); - console.log(` ${color('--api-key ', 'command')} API key (create)`); - console.log(` ${color('--model ', 'command')} Default model (create)`); - console.log( - ` ${color('--target ', '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 ', 'command')} Export bundle output path`); - console.log(` ${color('--include-secrets', 'command')} Include token in export bundle`); - console.log(` ${color('--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(''); -} - -/** Main api command router */ -export async function handleApiCommand(args: string[]): Promise { - const command = args[0]; - - if (!command || command === '--help' || command === '-h' || command === 'help') { - await showHelp(); - return; - } - - switch (command) { - case 'create': - await handleCreate(args.slice(1)); - break; - case 'list': - await handleList(); - break; - case 'discover': - await handleDiscover(args.slice(1)); - break; - case 'copy': - await handleCopy(args.slice(1)); - break; - case 'export': - await handleExport(args.slice(1)); - break; - case 'import': - await handleImport(args.slice(1)); - break; - case 'remove': - case 'delete': - case 'rm': - await handleRemove(args.slice(1)); - break; - default: - await initUI(); - console.log(fail(`Unknown command: ${command}`)); - console.log(''); - console.log('Run for help:'); - console.log(` ${color('ccs api --help', 'command')}`); - process.exit(1); - } -} +export * from './api-command/index'; diff --git a/src/commands/api-command/copy-command.ts b/src/commands/api-command/copy-command.ts new file mode 100644 index 00000000..b1be7d44 --- /dev/null +++ b/src/commands/api-command/copy-command.ts @@ -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 { + 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 ')); + 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(''); +} diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts new file mode 100644 index 00000000..5941e9d1 --- /dev/null +++ b/src/commands/api-command/create-command.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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(''); +} diff --git a/src/commands/api-command/discover-command.ts b/src/commands/api-command/discover-command.ts new file mode 100644 index 00000000..4b9c2f4d --- /dev/null +++ b/src/commands/api-command/discover-command.ts @@ -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 { + 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(''); +} diff --git a/src/commands/api-command/export-command.ts b/src/commands/api-command/export-command.ts new file mode 100644 index 00000000..8ce79789 --- /dev/null +++ b/src/commands/api-command/export-command.ts @@ -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 { + 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 [--out ]')); + 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(''); +} diff --git a/src/commands/api-command/help.ts b/src/commands/api-command/help.ts new file mode 100644 index 00000000..e9c5f26a --- /dev/null +++ b/src/commands/api-command/help.ts @@ -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 { + 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')} [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 ', 'command')} Duplicate API profile settings + config`); + console.log( + ` ${color('export ', 'command')} Export profile bundle for cross-device transfer` + ); + console.log( + ` ${color('import ', 'command')} Import profile bundle and register profile` + ); + console.log(` ${color('remove ', 'command')} Remove an API profile`); + console.log(''); + console.log(subheader('Options')); + console.log( + ` ${color('--preset ', 'command')} Use provider preset (${presetIds.join(', ')})` + ); + console.log(` ${color('--base-url ', 'command')} API base URL (create)`); + console.log(` ${color('--api-key ', 'command')} API key (create)`); + console.log(` ${color('--model ', 'command')} Default model (create)`); + console.log( + ` ${color('--target ', '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 ', 'command')} Export bundle output path`); + console.log(` ${color('--include-secrets', 'command')} Include token in export bundle`); + console.log(` ${color('--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 { + await initUI(); + console.log(fail(`Unknown command: ${command}`)); + console.log(''); + console.log('Run for help:'); + console.log(` ${color('ccs api --help', 'command')}`); + process.exit(1); +} diff --git a/src/commands/api-command/import-command.ts b/src/commands/api-command/import-command.ts new file mode 100644 index 00000000..cac4e949 --- /dev/null +++ b/src/commands/api-command/import-command.ts @@ -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 { + 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 [--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(''); +} diff --git a/src/commands/api-command/index.ts b/src/commands/api-command/index.ts new file mode 100644 index 00000000..79e6b560 --- /dev/null +++ b/src/commands/api-command/index.ts @@ -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 { + await dispatchNamedCommand({ + args, + routes: API_COMMAND_ROUTES, + onHelp: showApiCommandHelp, + onUnknown: showUnknownApiCommand, + allowEmptyHelp: true, + }); +} diff --git a/src/commands/api-command/list-command.ts b/src/commands/api-command/list-command.ts new file mode 100644 index 00000000..4f805d87 --- /dev/null +++ b/src/commands/api-command/list-command.ts @@ -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 { + 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(''); +} diff --git a/src/commands/api-command/remove-command.ts b/src/commands/api-command/remove-command.ts new file mode 100644 index 00000000..73b1e6ff --- /dev/null +++ b/src/commands/api-command/remove-command.ts @@ -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 { + 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(''); +} diff --git a/src/commands/api-command/shared.ts b/src/commands/api-command/shared.ts new file mode 100644 index 00000000..d01a3107 --- /dev/null +++ b/src/commands/api-command/shared.ts @@ -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(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); +} diff --git a/src/commands/arg-extractor.ts b/src/commands/arg-extractor.ts index 6c2709c3..26f7c5d9 100644 --- a/src/commands/arg-extractor.ts +++ b/src/commands/arg-extractor.ts @@ -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 }; +} diff --git a/src/commands/config-auth/index.ts b/src/commands/config-auth/index.ts index 567ffed6..bf1140cb 100644 --- a/src/commands/config-auth/index.ts +++ b/src/commands/config-auth/index.ts @@ -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 { + 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, + 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 { * Route config auth command to appropriate handler */ export async function handleConfigAuthCommand(args: string[]): Promise { - // 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 diff --git a/src/commands/config-command-options.ts b/src/commands/config-command-options.ts new file mode 100644 index 00000000..1e19e5db --- /dev/null +++ b/src/commands/config-command-options.ts @@ -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 Set analysis timeout (seconds)'); + console.log(' --set-model

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 Set mode (auto, off, manual)'); + console.log(' --override Set persistent override level'); + console.log(' --clear-override Remove persistent override'); + console.log(' --tier Set tier default level'); + console.log(' --provider-override

Set provider tier override'); + console.log(' --clear-provider-override

[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(''); +} diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index 7a7f5f4c..1965ce86 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -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 Set analysis timeout (seconds)'); - console.log(' --set-model

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 Set mode (auto, off, manual)'); - console.log(' --override Set persistent override level'); - console.log(' --clear-override Remove persistent override'); - console.log(' --tier Set tier default level'); - console.log(' --provider-override

Set provider tier override'); - console.log(' --clear-provider-override

[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 { - // 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 { try { // Start server - const { server, wss, cleanup } = await startServer({ port, dev: options.dev }); + const serverOptions: Parameters[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 { 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; +} diff --git a/src/commands/config-dashboard-host.ts b/src/commands/config-dashboard-host.ts new file mode 100644 index 00000000..e4dd8703 --- /dev/null +++ b/src/commands/config-dashboard-host.ts @@ -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; + +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(); + + 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; +} diff --git a/src/commands/env-command.ts b/src/commands/env-command.ts index 888f075d..bfb35103 100644 --- a/src/commands/env-command.ts +++ b/src/commands/env-command.ts @@ -209,6 +209,11 @@ export async function handleEnvCommand(args: string[]): Promise { let envVars: Record; 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)); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 6eb794c0..97e2af09 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -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 ', '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'], diff --git a/src/commands/named-command-router.ts b/src/commands/named-command-router.ts new file mode 100644 index 00000000..1748a094 --- /dev/null +++ b/src/commands/named-command-router.ts @@ -0,0 +1,58 @@ +export interface NamedCommandRoute { + name: string; + aliases?: readonly string[]; + handle(args: string[]): Promise | void; +} + +interface DispatchNamedCommandOptions { + args: string[]; + routes: readonly NamedCommandRoute[]; + onUnknown(command: string): Promise | void; + onHelp?: () => Promise | 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 { + 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; +} diff --git a/src/commands/persist-command.ts b/src/commands/persist-command.ts index a1d061ff..a5474db6 100644 --- a/src/commands/persist-command.ts +++ b/src/commands/persist-command.ts @@ -679,7 +679,7 @@ async function showHelp(): Promise { ); 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( diff --git a/src/commands/root-command-router.ts b/src/commands/root-command-router.ts new file mode 100644 index 00000000..07d72885 --- /dev/null +++ b/src/commands/root-command-router.ts @@ -0,0 +1,185 @@ +import { resolveNamedCommand, type NamedCommandRoute } from './named-command-router'; + +async function printUpdateCommandHelp(): Promise { + 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 { + const route = resolveNamedCommand(args[0], ROOT_COMMAND_ROUTES); + if (!route) { + return false; + } + + await route.handle(args.slice(1)); + return true; +} diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index cc8e0381..8bcc9445 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -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('# ----------------------------------------------------------------------------'); diff --git a/src/cursor/cursor-anthropic-response.ts b/src/cursor/cursor-anthropic-response.ts new file mode 100644 index 00000000..6575fd14 --- /dev/null +++ b/src/cursor/cursor-anthropic-response.ts @@ -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 | 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 { + 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 { + 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({ + 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 { + 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); +} diff --git a/src/cursor/cursor-anthropic-translator.ts b/src/cursor/cursor-anthropic-translator.ts new file mode 100644 index 00000000..6dbe7d92 --- /dev/null +++ b/src/cursor/cursor-anthropic-translator.ts @@ -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 { + if (typeof value !== 'object' || value === null) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +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 = []; + 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, + }; +} diff --git a/src/cursor/cursor-anthropic-types.ts b/src/cursor/cursor-anthropic-types.ts new file mode 100644 index 00000000..336fb75f --- /dev/null +++ b/src/cursor/cursor-anthropic-types.ts @@ -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; +} + +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; + }; +} diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index f6eb2f7d..bfcea61c 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -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, + }, + }); + } } }); diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts index 208a1a25..1bd9a31f 100644 --- a/src/cursor/cursor-models.ts +++ b/src/cursor/cursor-models.ts @@ -281,6 +281,48 @@ function getCatalogDefaultModelId(availableModels: CursorModel[]): string { return firstAvailable || DEFAULT_CURSOR_MODEL; } +function addLookupCandidate(candidates: Set, value: string): void { + const normalized = value.trim().toLowerCase(); + if (normalized) { + candidates.add(normalized); + } +} + +function buildCursorAnthropicModelLookupCandidates(requestedModel: string): string[] { + const candidates = new Set(); + 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; diff --git a/src/glmt/glmt-proxy.ts b/src/glmt/glmt-proxy.ts index 2d7be16c..f493ba3e 100644 --- a/src/glmt/glmt-proxy.ts +++ b/src/glmt/glmt-proxy.ts @@ -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 * diff --git a/src/glmt/sse-parser.ts b/src/glmt/sse-parser.ts index eaeb00dd..9a731654 100644 --- a/src/glmt/sse-parser.ts +++ b/src/glmt/sse-parser.ts @@ -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: ')) { diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index b6559720..7cdbdc97 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -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); diff --git a/src/management/recovery-manager.ts b/src/management/recovery-manager.ts index ec7e3710..313f6911 100644 --- a/src/management/recovery-manager.ts +++ b/src/management/recovery-manager.ts @@ -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 = []; diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 9a010edd..cc617d3b 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -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).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// 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// 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(); + + 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([ + 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// -> /.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}`)); } } diff --git a/src/shared/claude-extension-setup.ts b/src/shared/claude-extension-setup.ts index dddf3efd..784c8560 100644 --- a/src/shared/claude-extension-setup.ts +++ b/src/shared/claude-extension-setup.ts @@ -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.' diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index a1704d0d..0baa2270 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -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> = 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 { diff --git a/src/types/index.ts b/src/types/index.ts index 494883be..f88f447d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -32,7 +32,7 @@ export type { DelegationEvent, } from './delegation'; -// GLMT types +// Legacy GLMT transformer types export type { AnthropicMessage, ContentBlock, diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index 28b09105..7b8ce3cb 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -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 { diff --git a/src/utils/fetch-proxy-setup.ts b/src/utils/fetch-proxy-setup.ts index 7883bc2f..b52c6d74 100644 --- a/src/utils/fetch-proxy-setup.ts +++ b/src/utils/fetch-proxy-setup.ts @@ -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[0]; +type RoutingDispatchHandler = Parameters[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); } diff --git a/src/utils/glmt-deprecation.ts b/src/utils/glmt-deprecation.ts new file mode 100644 index 00000000..8235aea8 --- /dev/null +++ b/src/utils/glmt-deprecation.ts @@ -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; + 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): 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; +} diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 0d9cbd45..16861f00 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -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) { diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 61dd2941..6ec39e00 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -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((resolve) => { - server.listen(options.port, () => { + return new Promise((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; +} diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index f26071a1..81a63055 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -458,3 +458,4 @@ router.delete('/:name', (req: Request, res: Response): void => { }); export default router; +export { parseTarget } from './route-helpers'; diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts index 7e9013ef..483bf11d 100644 --- a/src/web-server/routes/variant-routes.ts +++ b/src/web-server/routes/variant-routes.ts @@ -295,3 +295,4 @@ router.delete('/:name', (req: Request, res: Response): void => { }); export default router; +export { parseTarget } from './route-helpers'; diff --git a/tests/README.md b/tests/README.md index 3f0091ed..c5e4acf4 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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//` using Mocha + Node.js assert -- **npm tests**: Add to `npm/` using Mocha +- **Unit tests**: Add to `unit//` 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` diff --git a/tests/integration/cursor-daemon-lifecycle.test.ts b/tests/integration/cursor-daemon-lifecycle.test.ts new file mode 100644 index 00000000..7a4e28ed --- /dev/null +++ b/tests/integration/cursor-daemon-lifecycle.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); + }); +}); diff --git a/tests/integration/glmt-integration-test.sh b/tests/integration/glmt-integration-test.sh index 84cf7ba2..2dfb0239 100755 --- a/tests/integration/glmt-integration-test.sh +++ b/tests/integration/glmt-integration-test.sh @@ -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 \" 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 diff --git a/tests/npm/cli.test.js b/tests/npm/cli.test.js index a89072ac..d32055b4 100644 --- a/tests/npm/cli.test.js +++ b/tests/npm/cli.test.js @@ -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 { diff --git a/tests/npm/postinstall.test.js b/tests/npm/postinstall.test.js index 136df681..f7f56700 100644 --- a/tests/npm/postinstall.test.js +++ b/tests/npm/postinstall.test.js @@ -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'); }); diff --git a/tests/unit/api/provider-presets-novita.test.ts b/tests/unit/api/provider-presets-novita.test.ts new file mode 100644 index 00000000..b820e536 --- /dev/null +++ b/tests/unit/api/provider-presets-novita.test.ts @@ -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); + }); +}); diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts index 2246ed6e..7a233f27 100644 --- a/tests/unit/api/provider-presets.test.ts +++ b/tests/unit/api/provider-presets.test.ts @@ -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); + } + }); }); diff --git a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts new file mode 100644 index 00000000..b22ac1fa --- /dev/null +++ b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts @@ -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; + }; + 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; + }; + 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; + }; + 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; + }; + 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; + }; + 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 }); + } + }); +}); diff --git a/tests/unit/cliproxy/codex-plan-compatibility.test.ts b/tests/unit/cliproxy/codex-plan-compatibility.test.ts new file mode 100644 index 00000000..cf84fd28 --- /dev/null +++ b/tests/unit/cliproxy/codex-plan-compatibility.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/cliproxy/variant-update-service.test.ts b/tests/unit/cliproxy/variant-update-service.test.ts index 28467094..fe63fb7d 100644 --- a/tests/unit/cliproxy/variant-update-service.test.ts +++ b/tests/unit/cliproxy/variant-update-service.test.ts @@ -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'); }); }); diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index b73e1e08..3ee6916b 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -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']); + }); }); diff --git a/tests/unit/commands/api-command-router.test.ts b/tests/unit/commands/api-command-router.test.ts new file mode 100644 index 00000000..25d33812 --- /dev/null +++ b/tests/unit/commands/api-command-router.test.ts @@ -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']); + }); +}); diff --git a/tests/unit/commands/api-export-command.test.ts b/tests/unit/commands/api-export-command.test.ts new file mode 100644 index 00000000..7538534b --- /dev/null +++ b/tests/unit/commands/api-export-command.test.ts @@ -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}`); + }); +}); diff --git a/tests/unit/commands/arg-extractor.test.ts b/tests/unit/commands/arg-extractor.test.ts index b65d6862..8ade4e18 100644 --- a/tests/unit/commands/arg-extractor.test.ts +++ b/tests/unit/commands/arg-extractor.test.ts @@ -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: [], + }); + }); + }); }); diff --git a/tests/unit/commands/config-auth-command.test.ts b/tests/unit/commands/config-auth-command.test.ts new file mode 100644 index 00000000..3f3182a7 --- /dev/null +++ b/tests/unit/commands/config-auth-command.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/commands/config-command-options.test.ts b/tests/unit/commands/config-command-options.test.ts new file mode 100644 index 00000000..abf95b1b --- /dev/null +++ b/tests/unit/commands/config-command-options.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/commands/config-command.test.ts b/tests/unit/commands/config-command.test.ts new file mode 100644 index 00000000..27c5b44c --- /dev/null +++ b/tests/unit/commands/config-command.test.ts @@ -0,0 +1,244 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; + +const startServerCalls: Array> = []; +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) => { + 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' + ); + }); +}); diff --git a/tests/unit/commands/config-dashboard-host.test.ts b/tests/unit/commands/config-dashboard-host.test.ts new file mode 100644 index 00000000..a0f85c4e --- /dev/null +++ b/tests/unit/commands/config-dashboard-host.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 27cd4de6..2ed57bd1 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -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); + }); }); diff --git a/tests/unit/commands/root-command-router.test.ts b/tests/unit/commands/root-command-router.test.ts new file mode 100644 index 00000000..ef09cf7c --- /dev/null +++ b/tests/unit/commands/root-command-router.test.ts @@ -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) => { + 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)'); + }); +}); diff --git a/tests/unit/cursor/cursor-anthropic-translator.test.ts b/tests/unit/cursor/cursor-anthropic-translator.test.ts new file mode 100644 index 00000000..0abaec2b --- /dev/null +++ b/tests/unit/cursor/cursor-anthropic-translator.test.ts @@ -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 = {}; + 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 = {}; + 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; + }>; + }; + + 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'); + }); +}); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 1ba7e553..3b5fc576 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -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', () => { diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts index 0390f5e1..a5095b5e 100644 --- a/tests/unit/cursor/cursor-models.test.ts +++ b/tests/unit/cursor/cursor-models.test.ts @@ -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); diff --git a/tests/unit/glmt/test-thinking-multi-message.js b/tests/unit/glmt/test-thinking-multi-message.js index d909fad7..ed7d62f0 100644 --- a/tests/unit/glmt/test-thinking-multi-message.js +++ b/tests/unit/glmt/test-thinking-multi-message.js @@ -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 ""'); +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); diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index 1297069d..1a39e803 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -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); + }); }); diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index c06e2b48..96ee5eee 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -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); + }); + }); }); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 64a2323a..0d10ebe0 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -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'; diff --git a/tests/unit/utils/glmt-deprecation.test.ts b/tests/unit/utils/glmt-deprecation.test.ts new file mode 100644 index 00000000..d003a9e7 --- /dev/null +++ b/tests/unit/utils/glmt-deprecation.test.ts @@ -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)); + }); +}); diff --git a/tests/unit/web-server/claude-extension-routes.test.ts b/tests/unit/web-server/claude-extension-routes.test.ts index 84e27c77..8dbc458a 100644 --- a/tests/unit/web-server/claude-extension-routes.test.ts +++ b/tests/unit/web-server/claude-extension-routes.test.ts @@ -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; + 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; 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` diff --git a/tests/unit/web-server/start-server-host.test.ts b/tests/unit/web-server/start-server-host.test.ts new file mode 100644 index 00000000..7c1204c6 --- /dev/null +++ b/tests/unit/web-server/start-server-host.test.ts @@ -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>> = []; + +afterEach(async () => { + while (instances.length > 0) { + const instance = instances.pop(); + if (!instance) { + continue; + } + + instance.cleanup(); + await new Promise((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); + }); +}); diff --git a/ui/README.md b/ui/README.md index cc1d3800..5c395658 100644 --- a/ui/README.md +++ b/ui/README.md @@ -18,7 +18,20 @@ From project root: bun run dev ``` -This starts the CCS server and serves the dashboard at `http://localhost:3000`. +This starts the CCS server, opens a local browser URL, and prints bind/network details for the dashboard. +If the runtime bind is reachable beyond loopback, CCS also prints an auth reminder. + +For remote device access during development, run: + +```bash +bun run dev -- --host 0.0.0.0 +``` + +For local-only development, run: + +```bash +bun run dev -- --host 127.0.0.1 +``` From `ui/` only (frontend dev server): diff --git a/ui/public/assets/providers/llama-cpp.svg b/ui/public/assets/providers/llama-cpp.svg new file mode 100644 index 00000000..9f2d0875 --- /dev/null +++ b/ui/public/assets/providers/llama-cpp.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/public/icons/novita.svg b/ui/public/icons/novita.svg new file mode 100644 index 00000000..356b6d69 --- /dev/null +++ b/ui/public/icons/novita.svg @@ -0,0 +1,9 @@ + + Novita AI + + diff --git a/ui/src/components/cliproxy/categorized-model-selector.tsx b/ui/src/components/cliproxy/categorized-model-selector.tsx index c52f8794..e80d9d1d 100644 --- a/ui/src/components/cliproxy/categorized-model-selector.tsx +++ b/ui/src/components/cliproxy/categorized-model-selector.tsx @@ -5,15 +5,7 @@ import { useMemo } from 'react'; import { Badge } from '@/components/ui/badge'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; +import { SearchableSelect } from '@/components/ui/searchable-select'; import { Skeleton } from '@/components/ui/skeleton'; import { Cpu } from 'lucide-react'; import type { CliproxyModelsResponse } from '@/lib/api-client'; @@ -94,36 +86,37 @@ export function CategorizedModelSelector({ } return ( - + ({ + key: category, + label: ( +

+ + {t(`cliproxyModelCategory.${display.key}`)} + + + {models.length} + +
+ ), + }))} + options={sortedCategories.flatMap(({ category, models }) => + models.map((model) => ({ + value: model.id, + groupKey: category, + searchText: model.id, + keywords: [category], + itemContent: {model.id}, + })) + )} + /> ); } diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 5b8d5ec6..af2a1afb 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -5,22 +5,15 @@ */ import { useMemo } from 'react'; -import { Badge } from '@/components/ui/badge'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Skeleton } from '@/components/ui/skeleton'; -import { AlertTriangle, AlertCircle, Check } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { getCodexEffortDisplay } from '@/lib/codex-effort'; +import { AlertCircle, AlertTriangle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { Badge } from '@/components/ui/badge'; +import { SearchableSelect } from '@/components/ui/searchable-select'; +import { Skeleton } from '@/components/ui/skeleton'; +import { getCodexEffortDisplay } from '@/lib/codex-effort'; +import { cn } from '@/lib/utils'; + /** Model entry from catalog */ export interface ModelEntry { id: string; @@ -67,6 +60,53 @@ interface ProviderModelSelectorProps { className?: string; } +function PaidBadge({ label }: { label: string }) { + return ( + + {label} + + ); +} + +function StatusBadges({ + model, + brokenLabel, + deprecatedLabel, +}: { + model: Pick; + brokenLabel: string; + deprecatedLabel: string; +}) { + return ( + <> + {model.broken && ( + + {brokenLabel} + + )} + {model.deprecated && ( + + {deprecatedLabel} + + )} + + ); +} + +function CodexEffortBadge({ modelId }: { modelId: string | undefined }) { + const codexEffort = getCodexEffortDisplay(modelId); + if (!codexEffort) return null; + + return ( + + {codexEffort.label} + + ); +} + export function ProviderModelSelector({ catalog, isLoading, @@ -79,18 +119,18 @@ export function ProviderModelSelector({ const { t } = useTranslation(); const resolvedPlaceholder = placeholder ?? t('providerModelSelector.selectModel'); - // Group models by tier const groupedModels = useMemo(() => { if (!catalog?.models) return { free: [], paid: [] }; return { - free: catalog.models.filter((m) => !m.tier || m.tier === 'free'), - paid: catalog.models.filter((m) => m.tier === 'paid'), + free: catalog.models.filter((model) => !model.tier || model.tier === 'free'), + paid: catalog.models.filter((model) => model.tier === 'paid'), }; }, [catalog]); - const selectedModel = useMemo(() => { - return catalog?.models.find((m) => m.id === value); - }, [catalog, value]); + const selectedModel = useMemo( + () => catalog?.models.find((model) => model.id === value), + [catalog, value] + ); if (isLoading) { return ; @@ -98,76 +138,69 @@ export function ProviderModelSelector({ if (!catalog || catalog.models.length === 0) { return ( -
+
{t('providerModelSelector.noModelsForProvider')}
); } - const renderModelItem = (model: ModelEntry) => ( - -
- {model.name} - {model.broken && ( - - {t('providerModelSelector.broken')} - - )} - {model.deprecated && ( - - {t('providerModelSelector.deprecated')} - - )} - {value === model.id && } -
-
- ); - return (
- + + ), + }, + { + key: 'paid', + label: ( + {t('providerModelSelector.paidTier')} + ), + }, + ]} + options={[...groupedModels.free, ...groupedModels.paid].map((model) => ({ + value: model.id, + groupKey: model.tier === 'paid' ? 'paid' : 'free', + searchText: `${model.name} ${model.id}`, + keywords: [model.tier ?? 'free'], + triggerContent: ( +
+ {model.name} + {model.tier === 'paid' && } +
+ ), + itemContent: ( +
+ {model.name} + +
+ ), + }))} + /> - {/* Warning for broken/deprecated models */} {selectedModel?.broken && ( -
- +
+

{t('providerModelSelector.modelKnownIssues')}

{selectedModel.issueUrl && ( @@ -185,8 +218,8 @@ export function ProviderModelSelector({ )} {selectedModel?.deprecated && ( -
- +
+

{t('providerModelSelector.modelDeprecated')}

{selectedModel.deprecationReason && ( @@ -196,7 +229,6 @@ export function ProviderModelSelector({
)} - {/* Model description */} {selectedModel?.description && !selectedModel.broken && !selectedModel.deprecated && (

{selectedModel.description}

)} @@ -226,27 +258,26 @@ export function ModelMappingSelector({ return (
- + ({ + value: model.id, + searchText: `${model.name} ${model.id}`, + triggerContent: {model.id}, + itemContent: ( +
+ {model.name} + {model.tier === 'paid' && } +
+ ), + }))} + />
); } @@ -272,10 +303,50 @@ export function FlexibleModelSelector({ disabled, }: FlexibleModelSelectorProps) { const { t } = useTranslation(); - // Combine catalog models (recommended) with all available models - const catalogModelIds = new Set(catalog?.models.map((m) => m.id) || []); + const catalogModelIds = new Set(catalog?.models.map((model) => model.id) || []); const isCodexProvider = catalog?.provider === 'codex'; - const selectedCodexEffort = isCodexProvider ? getCodexEffortDisplay(value) : null; + + const recommendedOptions = (catalog?.models ?? []).map((model) => ({ + value: model.id, + groupKey: 'recommended', + searchText: `${model.id} ${model.name}`, + keywords: [model.tier ?? '', catalog?.provider ?? ''], + triggerContent: ( +
+ {model.id} + {isCodexProvider && } +
+ ), + itemContent: ( +
+ {model.id} + {model.tier === 'paid' && } + {isCodexProvider && } +
+ ), + })); + + const allModelOptions = allModels + .filter((model) => !catalogModelIds.has(model.id)) + .map((model) => ({ + value: model.id, + groupKey: 'all', + searchText: model.id, + keywords: [model.owned_by], + triggerContent: ( +
+ {model.id} + {isCodexProvider && } +
+ ), + itemContent: ( +
+ {model.id} + {isCodexProvider && } +
+ ), + })); + const hasAvailableModels = recommendedOptions.length + allModelOptions.length > 0; return (
@@ -283,96 +354,36 @@ export function FlexibleModelSelector({ {description &&

{description}

}
- + + ), + }, + ]} + options={[...recommendedOptions, ...allModelOptions]} + />
); } diff --git a/ui/src/components/copilot/config-form/model-selector.tsx b/ui/src/components/copilot/config-form/model-selector.tsx index 1f3a91f0..6d5448a9 100644 --- a/ui/src/components/copilot/config-form/model-selector.tsx +++ b/ui/src/components/copilot/config-form/model-selector.tsx @@ -4,16 +4,7 @@ */ import { Badge } from '@/components/ui/badge'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - SelectGroup, - SelectLabel, -} from '@/components/ui/select'; -import { Check } from 'lucide-react'; +import { SearchableSelect } from '@/components/ui/searchable-select'; import type { FlexibleModelSelectorProps } from './types'; import { getPlanBadgeStyle, getMultiplierDisplay } from './utils'; import { useTranslation } from 'react-i18next'; @@ -27,8 +18,6 @@ export function FlexibleModelSelector({ disabled, }: FlexibleModelSelectorProps) { const { t } = useTranslation(); - // Find current model for display - const currentModel = models.find((m) => m.id === value); return (
@@ -36,58 +25,63 @@ export function FlexibleModelSelector({ {description &&

{description}

}
- + ({ + value: model.id, + groupKey: 'models', + searchText: `${model.name || model.id} ${model.id}`, + keywords: [model.minPlan ?? '', model.preview ? 'preview' : ''], + triggerContent: ( +
+ {model.id} + {model.minPlan && ( + + {model.minPlan} + + )} +
+ ), + itemContent: ( +
+ {model.name || model.id} + {model.minPlan && ( + + {model.minPlan} + + )} + {model.multiplier !== undefined && ( + + {getMultiplierDisplay(model.multiplier)} + + )} + {model.preview && ( + + {t('componentModelSelector.preview')} + + )} +
+ ), + }))} + />
); } diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index 5face9d6..55d82fd5 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -28,9 +28,20 @@ import { } from '@/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Badge } from '@/components/ui/badge'; +import { ProviderLogo } from '@/components/cliproxy/provider-logo'; import { useCreateProfile } from '@/hooks/use-profiles'; import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models'; -import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff, Settings2, Sparkles } from 'lucide-react'; +import { + Loader2, + Plus, + AlertTriangle, + Info, + Eye, + EyeOff, + Settings2, + Sparkles, + ChevronDown, +} from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; @@ -118,6 +129,7 @@ export function ProfileCreateDialog({ const [urlWarning, setUrlWarning] = useState(null); const [showApiKey, setShowApiKey] = useState(false); const [selectedPreset, setSelectedPreset] = useState(DEFAULT_PRESET_ID); + const [showMorePresets, setShowMorePresets] = useState(false); const [modelSearch, setModelSearch] = useState(''); // OpenRouter models for model picker @@ -180,6 +192,7 @@ export function ProfileCreateDialog({ setActiveTab('basic'); setUrlWarning(null); setShowApiKey(false); + setShowMorePresets(false); setModelSearch(''); // Set initial preset based on initialMode @@ -208,11 +221,13 @@ export function ProfileCreateDialog({ if (preset) { setSelectedPreset(preset.id); + setShowMorePresets(QUICK_TEMPLATE_PRESET_IDS.has(preset.id)); applyPresetToForm(preset); return; } setSelectedPreset(CUSTOM_PRESET_ID); + setShowMorePresets(false); applyPresetToForm(null); }; @@ -230,7 +245,7 @@ export function ProfileCreateDialog({ }; // Check for common URL mistakes - only for truly custom URLs - // Presets (OpenRouter, GLM, GLMT, Kimi) have vetted URLs that may require full paths + // Presets (OpenRouter, GLM, Kimi) have vetted URLs that may require full paths useEffect(() => { // Only warn for custom URLs, not preset-selected ones const isCustomUrl = selectedPreset === CUSTOM_PRESET_ID; @@ -277,6 +292,7 @@ export function ProfileCreateDialog({ const isQuickTemplateSelected = selectedPreset !== CUSTOM_PRESET_ID && QUICK_TEMPLATE_PRESET_IDS.has(selectedPreset); const isOpenRouter = currentPreset?.id === DEFAULT_PRESET_ID; + const showQuickTemplates = showMorePresets || isQuickTemplateSelected; return ( @@ -295,57 +311,81 @@ export function ProfileCreateDialog({ onSubmit={handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0 overflow-hidden" > - {/* Provider Preset Cards - Compact horizontal layout */} -
- {/* Main Options: OpenRouter + Custom */} -
- -
- {RECOMMENDED_PRESETS.map((preset) => ( - handlePresetSelect(preset.id)} - /> - ))} - {/* Custom option */} - +
+
+
+
+ +

+ {t('profileEditor.providerChooserHint')} +

+
+ + {t('profileEditor.scrollHint')} +
-
- {/* Show quick templates when custom mode or non-recommended preset is selected */} - {(selectedPreset === CUSTOM_PRESET_ID || isQuickTemplateSelected) && ( -
-
); } diff --git a/ui/tests/setup/vitest-setup.ts b/ui/tests/setup/vitest-setup.ts index e50b7023..3633d6ac 100644 --- a/ui/tests/setup/vitest-setup.ts +++ b/ui/tests/setup/vitest-setup.ts @@ -27,12 +27,13 @@ Object.defineProperty(window, 'matchMedia', { })), }); -// Mock ResizeObserver -global.ResizeObserver = vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), -})); +// Mock ResizeObserver with a constructible class for Radix/Floating UI usage +class ResizeObserverMock { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +} +global.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; // Mock localStorage const localStorageMock = { diff --git a/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx b/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx new file mode 100644 index 00000000..df222572 --- /dev/null +++ b/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog'; +import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils'; + +const mutateAsync = vi.fn(); + +vi.mock('@/hooks/use-profiles', () => ({ + useCreateProfile: () => ({ + mutateAsync, + isPending: false, + }), +})); + +vi.mock('@/hooks/use-openrouter-models', () => ({ + useOpenRouterCatalog: () => ({ + models: [], + }), +})); + +describe('ProfileCreateDialog', () => { + beforeEach(() => { + mutateAsync.mockReset(); + }); + + it('keeps advanced presets collapsed until explicitly opened and deselects custom after choosing a template', async () => { + render( + + ); + + expect(screen.getByText('Featured Providers')).toBeInTheDocument(); + expect(screen.getByText('Alibaba Coding Plan')).toBeVisible(); + const morePresetsToggle = screen.getByRole('button', { name: /More Presets/i }); + expect(morePresetsToggle).toHaveAttribute('aria-expanded', 'false'); + expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(1); + + const customButton = screen.getByRole('button', { name: /Custom Endpoint/i }); + await userEvent.click(customButton); + + expect(morePresetsToggle).toHaveAttribute('aria-expanded', 'false'); + expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(1); + + await userEvent.click(morePresetsToggle); + + expect(morePresetsToggle).toHaveAttribute('aria-expanded', 'true'); + expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(2); + + const glmButton = screen.getByText('GLM').closest('button'); + expect(glmButton).not.toBeNull(); + if (!glmButton) { + throw new Error('GLM preset button not found'); + } + await userEvent.click(glmButton); + + expect(morePresetsToggle).toHaveAttribute('aria-expanded', 'true'); + await waitFor(() => { + expect(customButton).not.toHaveClass('border-primary'); + }); + expect(glmButton).toHaveClass('border-primary'); + }); +}); diff --git a/ui/tests/unit/components/ui/searchable-select.test.tsx b/ui/tests/unit/components/ui/searchable-select.test.tsx new file mode 100644 index 00000000..4eafc880 --- /dev/null +++ b/ui/tests/unit/components/ui/searchable-select.test.tsx @@ -0,0 +1,139 @@ +import { useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SearchableSelect } from '@/components/ui/searchable-select'; +import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils'; + +function SearchableSelectHarness() { + const [value, setValue] = useState(); + + return ( + Claude Sonnet 4, + }, + { + value: 'gpt-5.3-codex', + groupKey: 'core', + searchText: 'GPT-5.3 Codex gpt-5.3-codex', + itemContent: GPT-5.3 Codex, + }, + { + value: 'gemini-2.5-pro', + groupKey: 'other', + searchText: 'Gemini 2.5 Pro gemini-2.5-pro', + itemContent: Gemini 2.5 Pro, + }, + ]} + /> + ); +} + +describe('SearchableSelect', () => { + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', { + configurable: true, + value: vi.fn(() => false), + }); + Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }); + }); + + it('autofocuses the search input, filters options, and updates the selection', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Select model' })); + + const searchInput = await screen.findByPlaceholderText('Search models...'); + await waitFor(() => { + expect(searchInput).toHaveFocus(); + }); + expect(searchInput).toHaveAttribute('role', 'combobox'); + + await userEvent.type(searchInput, 'gpt'); + + expect(screen.getByText('GPT-5.3 Codex')).toBeInTheDocument(); + expect(screen.queryByText('Claude Sonnet 4')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('option', { name: 'GPT-5.3 Codex' })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'GPT-5.3 Codex' })).toBeInTheDocument(); + }); + }); + + it('shows the empty state when the search query has no matches', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Select model' })); + await userEvent.type(await screen.findByPlaceholderText('Search models...'), 'no-match'); + + expect(screen.getByText('No results found.')).toBeInTheDocument(); + }); + + it('supports keyboard navigation and selection from the search input', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Select model' })); + + const searchInput = await screen.findByRole('combobox', { name: 'Search models...' }); + await waitFor(() => { + expect(searchInput).toHaveFocus(); + }); + + expect(searchInput).toHaveAttribute( + 'aria-activedescendant', + expect.stringContaining('claude-sonnet-4') + ); + + await userEvent.keyboard('[ArrowDown]'); + expect(searchInput).toHaveAttribute( + 'aria-activedescendant', + expect.stringContaining('gpt-5-3-codex') + ); + + await userEvent.keyboard('[Enter]'); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'GPT-5.3 Codex' })).toBeInTheDocument(); + }); + }); + + it('opens from the trigger with arrow keys', async () => { + render(); + + await userEvent.keyboard('[Tab][ArrowDown]'); + + const searchInput = await screen.findByRole('combobox', { name: 'Search models...' }); + await waitFor(() => { + expect(searchInput).toHaveFocus(); + }); + expect(searchInput).toHaveAttribute( + 'aria-activedescendant', + expect.stringContaining('claude-sonnet-4') + ); + }); +}); diff --git a/ui/tests/unit/ui/lib/provider-presets.test.ts b/ui/tests/unit/ui/lib/provider-presets.test.ts index 5dba0824..bac35a94 100644 --- a/ui/tests/unit/ui/lib/provider-presets.test.ts +++ b/ui/tests/unit/ui/lib/provider-presets.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { getPresetById, resolvePresetApiKeyValue } from '@/lib/provider-presets'; +import { + getPresetById, + getPresetsByCategory, + resolvePresetApiKeyValue, +} from '@/lib/provider-presets'; describe('resolvePresetApiKeyValue', () => { it('keeps an explicit API key when one is provided', () => { @@ -22,3 +26,22 @@ describe('resolvePresetApiKeyValue', () => { expect(resolvePresetApiKeyValue(preset, '')).toBe(''); }); }); + +describe('provider preset metadata', () => { + it('maps legacy glmt preset requests to glm', () => { + expect(getPresetById('glmt')?.id).toBe('glm'); + }); + + it('uses the llama.cpp provider logo asset for the local llama.cpp preset', () => { + expect(getPresetById('llamacpp')?.icon).toBe('/assets/providers/llama-cpp.svg'); + }); + + it('keeps Anthropic direct last in the recommended order', () => { + const recommendedPresetIds = getPresetsByCategory('recommended').map((preset) => preset.id); + expect(recommendedPresetIds.at(-1)).toBe('anthropic'); + }); + + it('reuses the Claude provider logo asset for Anthropic direct', () => { + expect(getPresetById('anthropic')?.icon).toBe('/assets/providers/claude.svg'); + }); +});