Merge pull request #917 from kaitranntt/dev

Release: merge dev into main
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-04-05 01:42:14 -04:00
commit 3439193fb2
133 files changed
+11406 -4368

No files matched your search

+19 -1
View File
@@ -17,7 +17,25 @@ 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, Kimi, and other API profiles). See README.md for user documentation.
Multi-provider profile and runtime manager for Claude Code, Factory Droid,
Codex CLI, and other compatible targets. See README.md for user documentation.
## README Preservation
When editing `README.md`, keep the file concise and funnel detailed usage into
the docs site, but **do not remove the `## Community Projects` section** or the
`## Star History` section unless the user explicitly asks for those sections to
be deleted. Treat both as protected README content.
When a contributor adds a useful community integration section to `README.md`,
prefer preserving the attribution in `## Community Projects` and moving the
setup substance into a docs page, rather than deleting the contribution.
Outside provider-specific Gemini and Antigravity docs, avoid using `ccs gemini`
or `ccs agy` as the primary hero example, default starter route, or generic
workflow example. Prefer `ccs`, `ccs codex`, `ccs kiro`, `ccs glm`, Droid
examples, or neutral `ccs <provider>` placeholders when the page is about a
broader topic.
## Design Principles (ENFORCE STRICTLY)
+121 -783
View File
@@ -4,809 +4,147 @@
![CCS Logo](assets/ccs-logo-medium.png)
### The universal AI profile manager for Claude Code.
Run Claude, Gemini, GLM, and any Anthropic-compatible API - concurrently, without conflicts.
### The multi-provider profile and runtime manager for Claude Code and compatible CLIs
Run Claude, Codex, Droid-routed profiles, GLM, local models, and
Anthropic-compatible APIs without config thrash.
[![License](https://img.shields.io/badge/license-MIT-C15F3C?style=for-the-badge)](LICENSE)
[![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 Hub](https://docs.ccs.kaitran.ca)**
**[Website](https://ccs.kaitran.ca)** |
**[Documentation](https://docs.ccs.kaitran.ca)** |
**[Product Tour](https://docs.ccs.kaitran.ca/getting-started/product-tour)** |
**[CLI Reference](https://docs.ccs.kaitran.ca/reference/cli-commands)**
</div>
<br>
## Why CCS
## The Three Pillars
CCS gives you one stable command surface while letting you switch between:
| Capability | What It Does | Manage Via |
|------------|--------------|------------|
| **Multiple Claude Accounts** | Run work + personal Claude subs simultaneously | Dashboard |
| **OAuth Providers** | Gemini, Codex, Antigravity - zero API keys needed | Dashboard |
| **API Profiles** | GLM, Kimi, or any Anthropic-compatible API | Dashboard |
- multiple runtimes such as Claude Code, Factory Droid, and Codex CLI
- multiple Claude subscriptions and isolated account contexts
- OAuth providers like Codex, Copilot, Kiro, Claude, Qwen, Kimi, and more
- API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp,
Novita, and Alibaba Coding Plan
<br>
The goal is simple: stop rewriting config files, stop breaking active sessions,
and move between providers in seconds.
## Quick Start
Looking for the full setup guide, command reference, provider guides, or troubleshooting?
Start at **https://docs.ccs.kaitran.ca**.
```bash
npm install -g @kaitranntt/ccs
ccs config
```
Then launch whatever runtime fits the task:
```bash
ccs
ccs codex
ccs --target droid glm
ccs glm
ccs ollama
```
Need the full setup path instead of the short version?
| Need | Start here |
| --- | --- |
| Install and verify CCS | [`/getting-started/installation`](https://docs.ccs.kaitran.ca/getting-started/installation) |
| First successful session | [`/getting-started/first-session`](https://docs.ccs.kaitran.ca/getting-started/first-session) |
| Visual walkthrough | [`/getting-started/product-tour`](https://docs.ccs.kaitran.ca/getting-started/product-tour) |
| Provider selection | [`/providers/concepts/overview`](https://docs.ccs.kaitran.ca/providers/concepts/overview) |
| Full command reference | [`/reference/cli-commands`](https://docs.ccs.kaitran.ca/reference/cli-commands) |
| Troubleshooting | [`/reference/troubleshooting`](https://docs.ccs.kaitran.ca/reference/troubleshooting) |
## See CCS In Action
### Usage Analytics
![Analytics Dashboard](assets/screenshots/analytics.webp)
Track usage, costs, and session patterns across profiles. Deep dive:
[Dashboard Analytics](https://docs.ccs.kaitran.ca/features/dashboard/analytics).
### Live Auth And Health Monitoring
![Live Auth Monitor](assets/screenshots/live-auth-monitor.webp)
See auth state, account health, and provider readiness without dropping into raw
config. Deep dive:
[Live Auth Monitor](https://docs.ccs.kaitran.ca/features/dashboard/live-auth-monitor).
### OAuth Provider Control Center
![CLIProxy API](assets/screenshots/cliproxyapi.webp)
Manage OAuth-backed providers, quota visibility, and routing from one place.
Deep dive:
[CLIProxy API](https://docs.ccs.kaitran.ca/features/proxy/cliproxy-api).
### Managed Tooling And Fallbacks
![WebSearch Fallback](assets/screenshots/websearch.webp)
CCS can provision first-class local tools like WebSearch and image analysis for
third-party launches instead of leaving you to wire them by hand. Deep dive:
[WebSearch](https://docs.ccs.kaitran.ca/features/ai/websearch).
## Docs Matrix
The README stays short on purpose. The docs site owns the detailed guides and
reference material.
| If you want to... | Read this |
| --- | --- |
| Understand what CCS is and how the pieces fit together | [Introduction](https://docs.ccs.kaitran.ca/introduction) |
| Install CCS cleanly on a new machine | [Installation](https://docs.ccs.kaitran.ca/getting-started/installation) |
| Go from install to a successful first run | [Your First CCS Session](https://docs.ccs.kaitran.ca/getting-started/first-session) |
| See the dashboard and workflow surfaces before setup | [Product Tour](https://docs.ccs.kaitran.ca/getting-started/product-tour) |
| Compare OAuth providers, Claude accounts, and API profiles | [Provider Overview](https://docs.ccs.kaitran.ca/providers/concepts/overview) |
| Learn the dashboard structure and feature pages | [Dashboard Overview](https://docs.ccs.kaitran.ca/features/dashboard/overview) |
| Configure profiles, paths, and environment variables | [Configuration](https://docs.ccs.kaitran.ca/getting-started/configuration) |
| Keep OpenCode aligned with your live CCS setup | [OpenCode Sync Plugin](https://docs.ccs.kaitran.ca/features/workflow/opencode-sync) |
| Browse every command and flag | [CLI Commands](https://docs.ccs.kaitran.ca/reference/cli-commands) |
| Recover from install, auth, or provider failures | [Troubleshooting](https://docs.ccs.kaitran.ca/reference/troubleshooting) |
| Understand storage, config, and architecture details | [Reference](https://docs.ccs.kaitran.ca/reference/architecture) |
## Example Workflow
```bash
# Design with default Claude
ccs "design the auth flow"
# Implement with a different provider
ccs codex "implement the user service"
# Use a cheaper API profile for routine work
ccs glm "clean up tests and docs"
# Run a local model when you need privacy or offline access
ccs ollama "summarize these logs"
```
## Community Projects
| Project | Author | Description |
| --- | --- | --- |
| [opencode-ccs-sync](https://github.com/JasonLandbridge/opencode-ccs-sync) | [@JasonLandbridge](https://github.com/JasonLandbridge) | Auto-sync CCS providers into OpenCode |
## Contribute And Report Safely
- Contributing guide: [CONTRIBUTING.md](./CONTRIBUTING.md)
- Starter work: [good first issue](https://github.com/kaitranntt/ccs/labels/good%20first%20issue), [help wanted](https://github.com/kaitranntt/ccs/labels/help%20wanted)
- Starter work:
[good first issue](https://github.com/kaitranntt/ccs/labels/good%20first%20issue),
[help wanted](https://github.com/kaitranntt/ccs/labels/help%20wanted)
- Questions: [open a question issue](https://github.com/kaitranntt/ccs/issues/new/choose)
- Security reports: [SECURITY.md](./SECURITY.md) and the [private advisory form](https://github.com/kaitranntt/ccs/security/advisories/new)
### 1. Install
```bash
npm install -g @kaitranntt/ccs
```
<details>
<summary>Alternative package managers</summary>
```bash
yarn global add @kaitranntt/ccs # yarn
pnpm add -g @kaitranntt/ccs # pnpm (70% less disk space)
bun add -g @kaitranntt/ccs # bun (30x faster)
```
</details>
### 2. Open Dashboard
```bash
ccs config
# 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`.
### 3. Configure Your Accounts
The dashboard provides visual management for all account types:
- **Claude Accounts**: Isolation-first by default (work, personal, client), with explicit shared context opt-in
- **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot
- **AI Providers**: Configure Gemini, Codex, Claude, Vertex, and OpenAI-compatible API keys under `CLIProxy -> AI Providers`
- **API Profiles**: Configure GLM, Kimi, OpenRouter, and other Anthropic-compatible APIs as CCS-native profiles
- **Codex CLI**: Dedicated dashboard page for native runtime diagnostics and guarded `config.toml` editing
- **Factory Droid**: Track Droid install location and BYOK settings health
- **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations)
- **Health Monitor**: Real-time status across all profiles
- **Language Switcher**: Toggle dashboard locale between English, Simplified Chinese, and Vietnamese
**Analytics Dashboard**
![Analytics](assets/screenshots/analytics.webp)
**Live Auth Monitor**
![Live Auth Monitor](assets/screenshots/live-auth-monitor.webp)
**CLI Proxy API & Copilot Integration**
![CLIProxy API](assets/screenshots/cliproxyapi.webp)
![Copilot API](assets/screenshots/copilot-api.webp)
**WebSearch Fallback**
![WebSearch](assets/screenshots/websearch.webp)
<br>
## Built-in Providers
| Provider | Auth Type | Command | Best For |
|----------|-----------|---------|----------|
| **Claude** | Subscription | `ccs` | Default, strategic planning |
| **Gemini** | OAuth | `ccs gemini` | Zero-config, fast iteration |
| **Codex** | OAuth | `ccs codex` | Code generation |
| **Copilot** | OAuth | `ccs copilot` or `ccs ghcp` | GitHub Copilot models |
| **Cursor IDE** | Local Token | `ccs cursor` | Cursor subscription models via local daemon |
| **Kiro** | OAuth (AWS default) | `ccs kiro` | AWS CodeWhisperer (Claude-powered) |
| **Antigravity** | OAuth | `ccs agy` | Alternative routing |
| **OpenRouter** | API Key | `ccs openrouter` | 300+ models, unified API |
| **Ollama** | Local | `ccs ollama` | Local open-source models, privacy |
| **llama.cpp** | Local | `ccs llamacpp` | Local GGUF inference via llama.cpp server |
| **Ollama Cloud** | API Key | `ccs ollama-cloud` | Cloud-hosted open-source models |
| **GLM** | API Key | `ccs glm` | Cost-optimized execution |
| **KM (Kimi API)** | API Key | `ccs km` | Long-context, thinking mode |
| **Kimi (OAuth)** | OAuth | `ccs kimi` | Device-code OAuth via CLIProxy |
| **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 |
**OpenRouter Integration** (v7.0.0): CCS v7.0.0 adds OpenRouter with interactive model picker, dynamic discovery, and tier mapping (opus/sonnet/haiku). Create via `ccs api create --preset openrouter` or dashboard.
**Alibaba Coding Plan Integration**: Configure via `ccs api create --preset alibaba-coding-plan` (or preset alias `alibaba`) with Coding Plan keys (`sk-sp-...`) and endpoint `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic`.
**Ollama Integration**: Run local open-source models (qwen3-coder, gpt-oss:20b) with full privacy. Use `ccs api create --preset ollama` - requires [Ollama v0.14.0+](https://ollama.com) installed. For cloud models, use `ccs api create --preset ollama-cloud`.
> **Third-party WebSearch steering:** Claude-backed third-party launches keep Anthropic's native `WebSearch` disabled, provision `ccs-websearch.WebSearch` when the managed runtime is available, and append a short system hint so Claude prefers that managed tool over ad hoc Bash or `curl` lookups whenever current web information is needed.
> Setting `websearch.enabled: false` disables the managed local runtime, but CCS still suppresses Anthropic's native `WebSearch` on third-party backends because those providers cannot execute it correctly.
> **Image backend visibility:** `ccs config image-analysis --set-fallback <backend>` defines the backend CCS should use when a profile alias cannot be inferred directly. Use `--set-profile-backend <profile> <backend>` and `--clear-profile-backend <profile>` for explicit per-profile mappings. In the dashboard, the global `Settings -> Image` section now shows the shared backend routing state, while each profile editor keeps a compact `Image` status card that links back to those global controls.
> **Copilot config behavior:** Opening the dashboard or other read-only Copilot endpoints does not rewrite `~/.ccs/copilot.settings.json`. If CCS detects deprecated Copilot model IDs such as `raptor-mini`, it shows warnings immediately and only persists replacements when you explicitly save the Copilot configuration.
**llama.cpp Integration**: Run a local llama.cpp OpenAI-compatible server and create a profile with `ccs api create --preset llamacpp`. CCS defaults to `http://127.0.0.1:8080`, matching the standard llama.cpp server port.
**Azure Foundry**: Use `ccs api create --preset foundry` to set up Claude via Microsoft Azure AI Foundry. Requires Azure resource and API key from [ai.azure.com](https://ai.azure.com).
![OpenRouter API Profiles](assets/screenshots/api-profiles-openrouter.webp)
> **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`.
> **Kiro / Copilot account naming:** Manual nicknames are optional. If the provider does not expose an email, CCS derives a safe internal identifier automatically and you can rename it later.
> **AI Providers dashboard:** Configure CLIProxy-managed API key families at `ccs config` -> `CLIProxy` -> `AI Providers`. Use `API Profiles` only for CCS-native Anthropic-compatible profiles.
**Powered by:**
- [CLIProxyAPIPlus](https://github.com/router-for-me/CLIProxyAPIPlus) - Extended OAuth proxy with Kiro ([@fuko2935](https://github.com/fuko2935), [@Ravens2121](https://github.com/Ravens2121)) and Copilot ([@em4go](https://github.com/em4go)) support
- [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) - Core OAuth proxy for Gemini, Codex, Antigravity
- [copilot-api](https://github.com/ericc-ch/copilot-api) - GitHub Copilot API integration
> [!TIP]
> **Need more?** CCS supports **any Anthropic-compatible API**. Create custom profiles for self-hosted LLMs, enterprise gateways, or alternative providers. See [API Profiles documentation](https://docs.ccs.kaitran.ca/providers/api-profiles).
<br>
## Usage
### Basic Commands
```bash
ccs # Default Claude session
ccs gemini # Gemini (OAuth)
ccs codex # OpenAI Codex (OAuth)
ccs cursor # Cursor IDE integration (token import + local daemon)
ccs kiro # Kiro/AWS CodeWhisperer (OAuth)
ccs ghcp # GitHub Copilot (OAuth device flow)
ccs agy # Antigravity (OAuth)
ccs qwen # Qwen Code (OAuth via CLIProxy)
ccs ollama # Local Ollama (no API key needed)
ccs llamacpp # Local llama.cpp (no API key needed)
ccs glm # GLM (API key)
ccs km # Kimi API profile (API key)
ccs api create --preset alibaba-coding-plan # Alibaba Coding Plan profile
ccs api discover --register # Auto-register orphan *.settings.json
ccs api copy glm glm-backup # Duplicate profile config + settings
ccs api export glm --out ./glm.ccs-profile.json # Export for cross-device transfer
ccs api import ./glm.ccs-profile.json # Import exported profile bundle
```
### Runtime Aliases (built-in bins / `argv[0]` pattern)
Built-in Droid and native Codex runtime aliases are installed with the package:
```bash
ccs-droid glm # explicit alias
ccsd glm # legacy shortcut
ccs-codex # explicit native Codex alias
ccsx # short native Codex alias
```
CCS also ships an opinionated Codex provider shortcut:
```bash
ccsxp # same as: ccs codex --target codex
```
Need additional alias names? First create the matching symlink or another launcher that
preserves the invoked basename, then map that name with `CCS_TARGET_ALIASES` (preferred) or legacy
target-specific env vars:
```bash
ln -s "$(command -v ccs)" /usr/local/bin/mydroid
ln -s "$(command -v ccs)" /usr/local/bin/mycodex
CCS_TARGET_ALIASES='droid=mydroid;codex=mycodex'
# Legacy fallback still supported:
CCS_DROID_ALIASES='mydroid'
CCS_CODEX_ALIASES='mycodex'
```
For Factory BYOK compatibility, CCS also stores a per-profile Droid provider hint
(`CCS_DROID_PROVIDER`) using one of:
`anthropic`, `openai`, or `generic-chat-completion-api`.
If the hint is missing, CCS resolves provider from base URL/model at runtime.
CCS also persists Droid's active model selector in `~/.factory/settings.json`
(`model: custom:<alias>`). This avoids passing `-m` argv in interactive mode,
which Droid treats as queued prompt text.
CCS supports structural Droid command passthrough after profile selection:
```bash
ccs-droid codex exec --skip-permissions-unsafe "fix failing tests"
ccs-droid codex --skip-permissions-unsafe "fix failing tests" # auto-routed to: droid exec ...
ccs-droid codex -m custom:gpt-5.3-codex "fix failing tests" # short exec flags auto-routed too
```
If you pass exec-only flags without a prompt (for example `--skip-permissions-unsafe`),
Droid `exec` will return its native "No prompt provided" usage guidance.
If multiple reasoning flags are provided in Droid exec mode, CCS keeps the first
flag and warns about duplicates.
Dashboard parity: `ccs config` -> `Factory Droid`
### Native Codex Runtime (runtime-only in v1)
CCS can launch native Codex as a first-class runtime target without rewriting your
`~/.codex/config.toml` on every run. CCS uses transient `codex -c key=value` overrides for
Codex-routed sessions and leaves your existing Codex home/config in place.
Supported in v1:
```bash
ccs --target codex # native Codex default session
ccs-codex # explicit Codex alias
ccsx # short native Codex alias
ccsxp # built-in CCS Codex provider on native Codex
ccs codex --target codex # explicit equivalent of ccsxp
ccs api create codex-api --cliproxy-provider codex
ccs codex-api --target codex # Codex bridge profile on native Codex
```
Not supported in v1:
- Claude account profiles on Codex target
- Copilot profiles on Codex target
- Generic API profiles that are not Codex-routed CLIProxy bridges
- Non-Codex CLIProxy providers on Codex target
- Composite CLIProxy variants on Codex target
Dashboard parity: `ccs config` -> `Compatible` -> `Codex CLI`
The dedicated Codex dashboard reads and writes the user layer only: `~/.codex/config.toml`
(or `$CODEX_HOME/config.toml`). It now ships as a split-view control center:
- left pane: guided controls for top-level runtime defaults, project trust, profiles,
model providers, MCP servers, and supported feature toggles
- right pane: raw `config.toml` editor for unsupported or exact-fidelity edits
- overview/docs tabs: binary detection, user-layer summary, support matrix guidance, and
upstream OpenAI references
Structured saves intentionally normalize TOML formatting and drop comments. Use the raw editor
when exact layout matters. Structured edits also refresh the raw snapshot immediately. Guided
controls stay disabled while the raw editor has unsaved or invalid TOML, project trust paths must
be absolute or start with `~/`, and supported feature flags can be cleared back to Codex defaults
with `Use default`. CCS also keeps warning that transient runtime overrides such as
`codex -c key=value` and `CCS_CODEX_API_KEY` can change the effective runtime without persisting
back into the user config file.
#### CLIProxy-backed native Codex
There are two supported ways to use CLIProxy with native Codex:
1. `ccsxp` or `ccs codex --target codex`
Uses the built-in CCS Codex provider route on native Codex. This path relies on transient
CCS-managed `-c` overrides and does **not** require changing your saved `model_provider`.
2. Plain `codex` or a personal alias like `cxp`
Save CLIProxy as the native default provider in `~/.codex/config.toml` (or
`$CODEX_HOME/config.toml`), then export `CLIPROXY_API_KEY` in your shell.
```toml
model_provider = "cliproxy"
[model_providers.cliproxy]
base_url = "http://127.0.0.1:8317/api/provider/codex"
env_key = "CLIPROXY_API_KEY"
wire_api = "responses"
```
The top-level `model_provider = "cliproxy"` line is required. Defining only
`[model_providers.cliproxy]` is not enough for plain `codex` to pick it by default.
```bash
export CLIPROXY_API_KEY="ccs-internal-managed"
ccsxp "your prompt" # CCS shortcut for the built-in provider route
codex "your prompt" # native Codex using your saved cliproxy default
```
Dashboard parity: `ccs config` -> `Compatible` -> `Codex CLI`
- `Overview`: explains `ccsx` vs `ccsxp`
- `Top-level settings`: set **Default provider** to `cliproxy`
- `Model providers`: save `cliproxy` with `env_key = "CLIPROXY_API_KEY"`
### Per-Profile Target Defaults
You can pin a default target (`claude` or `droid`) per profile:
```bash
# API profile defaults to Droid
ccs api create myglm --preset glm --target droid
# CLIProxy variant defaults to Droid
ccs cliproxy create mycodex --provider codex --target droid
```
Built-in CLIProxy providers also work with Droid alias/target override:
```bash
ccs-droid codex
ccs-droid agy
ccs codex --target droid
ccs-droid codex exec --auto high "triage this bug report"
```
Dashboard parity:
- `ccs config` -> `API Profiles` -> set **Default Target**
- `ccs config` -> `CLIProxy` -> create/edit variant -> set **Default Target**
### Kiro Auth Methods
`ccs kiro --auth` defaults to AWS Builder ID Device OAuth (best support for AWS org accounts).
```bash
ccs kiro --auth --kiro-auth-method aws # AWS Builder ID device code (default)
ccs kiro --auth --kiro-auth-method aws-authcode # AWS Builder ID auth code
ccs kiro --auth --kiro-auth-method google # Google OAuth
ccs kiro --auth --kiro-auth-method github # Dashboard management OAuth flow
```
Dashboard parity: `ccs config` -> Accounts -> Add Kiro account -> choose `Auth Method`.
### Cursor IDE Quick Start
```bash
ccs cursor enable
ccs cursor auth
ccs cursor start
ccs cursor status
```
If auto-detect is unavailable:
```bash
ccs cursor auth --manual --token <token> --machine-id <machine-id>
```
Defaults:
- Port: `20129`
- Ghost mode: enabled
- Dashboard page: `ccs config` -> `Cursor IDE`
Detailed guide: [`docs/cursor-integration.md`](./docs/cursor-integration.md)
### Claude IDE Extension Setup
CCS now has a native setup flow for the Anthropic Claude extension in VS Code and compatible hosts.
Use the same resolver in both the CLI and dashboard, so API profiles, CCS auth accounts,
CLIProxy-backed profiles, Copilot, and default-profile continuity all map to the correct env shape.
Preferred shared-settings path:
```bash
ccs persist glm
ccs persist work
ccs persist default
```
This writes the resolved setup to `~/.claude/settings.json`, which is the best option when you want
the Claude CLI and the IDE extension to share one CCS profile.
IDE-local snippet path:
```bash
ccs env glm --format claude-extension --ide vscode
ccs env work --format claude-extension --ide cursor
ccs env default --format claude-extension --ide windsurf
```
This prints a copy-ready `settings.json` snippet for the installed Claude extension host:
- `vscode` / `cursor`: `claudeCode.environmentVariables` plus `claudeCode.disableLoginPrompt`
- `windsurf`: `claude-code.environmentVariables`
Account and continuity-aware flows use `CLAUDE_CONFIG_DIR` instead of Anthropic transport env vars.
CLIProxy and Copilot flows emit the required `ANTHROPIC_*` variables and still depend on their local
proxy/daemon being reachable.
Dashboard parity:
- `ccs config` -> `Claude Extension`
- Select a CCS profile and IDE host to copy either the shared `~/.claude/settings.json` payload or the IDE-local extension snippet
### Parallel Workflows
Run multiple terminals with different providers:
> Delegation compatibility: when CCS spawns child Claude sessions, it strips the `CLAUDECODE` guard variable to avoid nested-session blocking in Claude Code v2.1.39+.
```bash
# Terminal 1: Planning (Claude Pro)
ccs work "design the authentication system"
# Terminal 2: Execution (GLM - cost optimized)
ccs glm "implement the user service from the plan"
# Terminal 3: Local testing (Ollama - offline, privacy)
ccs ollama "run tests and generate coverage report"
# Terminal 4: Review (Gemini)
ccs gemini "review the implementation for security issues"
```
### Multi-Account Claude
Create isolated Claude instances for work/personal separation:
```bash
ccs auth create work
# Run concurrently in separate terminals
ccs work "implement feature" # Terminal 1
ccs "review code" # Terminal 2 (personal account)
```
#### Account Context Modes (Isolation-First)
Account profiles are isolated by default.
| Mode | Default | Requirements |
|------|---------|--------------|
| `isolated` | Yes | No `context_group` required |
| `shared` | No (explicit opt-in) | Valid non-empty `context_group` |
Shared mode continuity depth:
- `standard` (default): shares project workspace context only
- `deeper` (advanced opt-in): additionally syncs `session-env`, `file-history`, `shell-snapshots`, `todos`
Opt in to shared context when needed:
```bash
# Share context with default group
ccs auth create backup --share-context
# Share context only within named group
ccs auth create backup2 --context-group sprint-a
# Advanced deeper continuity mode (requires shared mode)
ccs auth create backup3 --context-group sprint-a --deeper-continuity
```
Update existing accounts without recreating login:
1. Run `ccs config`
2. Open `Accounts`
3. Click the pencil icon in Actions and set `isolated` or `shared` mode + continuity depth
Shared mode metadata in `~/.ccs/config.yaml`:
```yaml
accounts:
work:
created: "2026-02-24T00:00:00.000Z"
last_used: null
context_mode: "shared"
context_group: "team-alpha"
continuity_mode: "standard"
```
`context_group` rules:
- lowercase letters, numbers, `_`, `-`
- must start with a letter
- max length `64`
- non-empty after normalization
- normalized by trim + lowercase + whitespace collapse (`" Team Alpha "` -> `"team-alpha"`)
Shared context with `standard` depth links project workspace data. `deeper` depth links additional continuity artifacts. Credentials remain isolated per account.
#### Cross-Profile Continuity Inheritance (Claude Target)
You can map non-account profiles (API, CLIProxy, Copilot, or `default`) to reuse continuity artifacts from an account profile:
```yaml
continuity:
inherit_from_account:
glm: pro
gemini: pro
copilot: pro
```
With this config, `ccs glm`, `ccs gemini`, and `ccs copilot` run with `pro`'s `CLAUDE_CONFIG_DIR` continuity context while keeping each profile's own provider credentials/settings.
Alternative path for lower manual switching:
- Use CLIProxy Claude pool (`ccs cliproxy auth claude`) and manage pool behavior in `ccs config` -> `CLIProxy Plus`.
Technical details: [`docs/session-sharing-technical-analysis.md`](docs/session-sharing-technical-analysis.md)
<br>
## Maintenance
### Health Check
```bash
ccs doctor
```
Verifies: Claude CLI, config files, symlinks, permissions.
### Update
```bash
ccs update # Update to latest
ccs update --force # Force reinstall
ccs update --beta # Install dev channel
```
### CI Parity Gate (for contributors)
Before opening or updating a PR, run:
```bash
bun run validate:ci-parity
```
This mirrors CI behavior (build + validate + base-branch freshness check) and is also enforced by the local `pre-push` hook.
### Sync Shared Items
```bash
ccs sync
```
Re-creates symlinks for shared commands, skills, and settings.
### Quota Management
```bash
ccs cliproxy doctor # Check quota status for all agy accounts
ccs cliproxy quota # Show agy/claude/codex/gemini/ghcp quotas (Claude/Codex: 5h + weekly reset schedule)
```
**Auto-Failover**: When a managed account runs out of quota, CCS automatically switches to another account with remaining capacity. Shared GCP project accounts are excluded (pooled quota).
### CLIProxy Lifecycle
```bash
ccs cliproxy start # Start CLIProxy background service
ccs cliproxy status # Check running status
ccs cliproxy restart # Restart CLIProxy service
ccs cliproxy stop # Stop running CLIProxy service
```
<br>
## Configuration
CCS auto-creates config on install. Dashboard is the recommended way to manage settings.
**Config location**: `~/.ccs/config.yaml`
<details>
<summary>Custom Claude CLI path</summary>
If Claude CLI is installed in a non-standard location:
```bash
export CCS_CLAUDE_PATH="/path/to/claude" # Unix
$env:CCS_CLAUDE_PATH = "D:\Tools\Claude\claude.exe" # Windows
```
CCS sanitizes child Claude spawn environments by stripping `CLAUDECODE` (case-insensitive) to prevent nested-session guard failures during delegation. `CCS_CLAUDE_PATH` is still respected after this sanitization step.
</details>
<details>
<summary>Windows symlink support</summary>
Enable Developer Mode for true symlinks:
1. **Settings** → **Privacy & Security** → **For developers**
2. Enable **Developer Mode**
3. Reinstall: `npm install -g @kaitranntt/ccs`
Without Developer Mode, CCS falls back to copying directories.
</details>
<br>
## WebSearch
Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS now provisions a first-class local `ccs-websearch` MCP tool when the managed runtime is available, disables native `WebSearch` on third-party launches, and steers Claude toward real local providers instead of surfacing a denied native-tool call.
### How It Works
| Profile Type | WebSearch Method |
|--------------|------------------|
| Claude (native) | Anthropic WebSearch API |
| Third-party profiles | CCS local MCP `WebSearch` tool when available; otherwise Bash/network fallback |
### Local Search Backend Chain
For third-party profiles, CCS steers Claude toward the managed `ccs-websearch.WebSearch` MCP tool when it is available. The tool is intentionally named to match the native `WebSearch` concept, which helps Claude prefer it over ad hoc Bash or `curl` fetches, but Bash/network fallback can still happen if the tool is unavailable or ignored. When the tool is used, CCS routes that request through deterministic search providers in this order:
| Priority | Provider | Setup | Notes |
|----------|----------|-------|-------|
| 1st | Exa | `EXA_API_KEY` | API-backed search with extracted content |
| 2nd | Tavily | `TAVILY_API_KEY` | Agent-oriented search API |
| 3rd | Brave Search | `BRAVE_API_KEY` | Cleaner API-backed results |
| 4th | DuckDuckGo | None | Built-in default fallback |
| 5th | Gemini / OpenCode / Grok | Optional | Legacy compatibility fallback only |
### Configuration
Configure via dashboard (**Settings** page) or `~/.ccs/config.yaml`:
```yaml
websearch:
enabled: true # Enable/disable (default: true)
providers:
exa:
enabled: false # Enable when EXA_API_KEY is set
tavily:
enabled: false # Enable when TAVILY_API_KEY is set
duckduckgo:
enabled: true # Built-in zero-setup fallback
brave:
enabled: false # Enable when BRAVE_API_KEY is set
gemini:
enabled: false # Optional legacy fallback
```
> [!TIP]
> **DuckDuckGo** still works out of the box. Add **Exa**, **Tavily**, or **Brave Search** if you want API-backed results, then keep Gemini/OpenCode/Grok only if you explicitly want legacy fallback behavior.
> CCS manages the user-scope MCP entry in `~/.claude.json` and syncs it into isolated account configs when needed.
> [!NOTE]
> Set `CCS_WEBSEARCH_TRACE=1` to write correlated launch, MCP, provider, and headless summary records to `~/.ccs/logs/websearch-trace.jsonl`. That trace is designed to answer whether CCS exposed the managed tool, whether Claude called it, which provider won, and when a headless run likely bypassed it via `Bash` or `WebFetch`.
See [docs/websearch.md](./docs/websearch.md) for detailed configuration and troubleshooting.
<br>
## Remote CLIProxy
CCS v7.x supports connecting to remote CLIProxyAPI instances, enabling:
- **Team sharing**: One CLIProxyAPI server for multiple developers
- **Cost optimization**: Centralized API key management
- **Network isolation**: Keep API credentials on a secure server
### Quick Setup
Configure via dashboard (**Settings > CLIProxy Server**) or CLI flags:
```bash
ccs gemini --proxy-host 192.168.1.100 --proxy-port 8317
ccs codex --proxy-host proxy.example.com --proxy-protocol https
```
### CLI Flags
| Flag | Description |
|------|-------------|
| `--proxy-host` | Remote proxy hostname or IP |
| `--proxy-port` | Remote proxy port (default: 8317 for HTTP, 443 for HTTPS) |
| `--proxy-protocol` | `http` or `https` (default: http) |
| `--proxy-auth-token` | Bearer token for authentication |
| `--local-proxy` | Force local mode, ignore remote config |
| `--remote-only` | Fail if remote unreachable (no fallback) |
See [Remote Proxy documentation](https://docs.ccs.kaitran.ca/features/remote-proxy) for detailed setup.
<br>
## Standard Fetch Proxy
CCS also respects standard proxy environment variables for fetch-based quota, dashboard,
and provider management requests:
```bash
export HTTPS_PROXY=http://proxy.example.com:8080
export HTTP_PROXY=http://proxy.example.com:8080
export ALL_PROXY=http://proxy.example.com:8080
export NO_PROXY=localhost,127.0.0.1,.internal.corp
```
Notes:
- CCS automatically bypasses loopback addresses (`localhost`, `127.0.0.1`, `::1`) for its own local services.
- If `HTTPS_PROXY` is unset, CCS falls back to `HTTP_PROXY` for HTTPS fetches.
- `ALL_PROXY` is used when protocol-specific proxy variables are not configured.
- Proxy URLs must use `http://` or `https://`.
<br>
## 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) |
| Multi-Account Claude | [docs.ccs.kaitran.ca/providers/claude-accounts](https://docs.ccs.kaitran.ca/providers/claude-accounts) |
| API Profiles | [docs.ccs.kaitran.ca/providers/api-profiles](https://docs.ccs.kaitran.ca/providers/api-profiles) |
| Remote Proxy | [docs.ccs.kaitran.ca/features/remote-proxy](https://docs.ccs.kaitran.ca/features/remote-proxy) |
| Cursor IDE (local guide) | [./docs/cursor-integration.md](./docs/cursor-integration.md) |
| Dashboard i18n (local guide) | [./docs/i18n-dashboard.md](./docs/i18n-dashboard.md) |
| CLI Reference | [docs.ccs.kaitran.ca/reference/cli-commands](https://docs.ccs.kaitran.ca/reference/cli-commands) |
| Architecture | [docs.ccs.kaitran.ca/reference/architecture](https://docs.ccs.kaitran.ca/reference/architecture) |
| Troubleshooting | [docs.ccs.kaitran.ca/reference/troubleshooting](https://docs.ccs.kaitran.ca/reference/troubleshooting) |
<br>
## Uninstall
```bash
npm uninstall -g @kaitranntt/ccs
```
<details>
<summary>Alternative package managers</summary>
```bash
yarn global remove @kaitranntt/ccs
pnpm remove -g @kaitranntt/ccs
bun remove -g @kaitranntt/ccs
```
</details>
<br>
## Philosophy
- **YAGNI**: No features "just in case"
- **KISS**: Simple, focused implementation
- **DRY**: One source of truth (config)
<br>
## Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md). For suspected vulnerabilities, use [SECURITY.md](./SECURITY.md) instead of a public issue.
<br>
## License
MIT License - see [LICENSE](LICENSE).
<br>
<div align="center">
- Security reports: [SECURITY.md](./SECURITY.md) and the
[private advisory form](https://github.com/kaitranntt/ccs/security/advisories/new)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=kaitranntt/ccs&type=date&legend=top-left)](https://www.star-history.com/#kaitranntt/ccs&type=date&legend=top-left)
---
**[ccs.kaitran.ca](https://ccs.kaitran.ca)** | **[docs.ccs.kaitran.ca](https://docs.ccs.kaitran.ca)** | [Issue Templates](https://github.com/kaitranntt/ccs/issues/new/choose) | [Private Security Report](https://github.com/kaitranntt/ccs/security/advisories/new) | [Star on GitHub](https://github.com/kaitranntt/ccs)
</div>
+8 -5
View File
@@ -54,7 +54,7 @@ src/
│ │ └── [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)
│ ├── config-image-analysis-command.ts # First-class ImageAnalysis 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)
@@ -149,7 +149,7 @@ src/
│ ├── index.ts # Barrel export
│ ├── checks/ # Diagnostic checks
│ │ ├── index.ts
│ │ └── image-analysis-check.ts # Image hook validation (NEW v7.34)
│ │ └── image-analysis-check.ts # ImageAnalysis runtime validation (NEW v7.34)
│ └── repair/ # Auto-repair logic
│ └── index.ts
│
@@ -169,15 +169,17 @@ src/
│ │ └── spinners.ts # Progress spinners
│ ├── websearch/ # Search tool integrations
│ │ └── index.ts
│ ├── hooks/ # Claude Code hooks (NEW v7.34)
│ ├── hooks/ # Claude Code compatibility hooks (NEW v7.34)
│ │ ├── index.ts
│ │ ├── image-analyzer-hook-installer.ts
│ │ ├── image-analyzer-hook-configuration.ts
│ │ ├── image-analyzer-profile-hook-injector.ts
│ │ └── get-image-analysis-hook-env.ts
│ ├── image-analysis/ # Image analysis hook utilities (NEW v7.34)
│ ├── image-analysis/ # ImageAnalysis MCP/runtime utilities (NEW v7.34)
│ │ ├── index.ts
│ │ └── hook-installer.ts
│ │ ├── hook-installer.ts
│ │ ├── mcp-installer.ts
│ │ └── claude-tool-args.ts
│ └── [utility files...]
│
└── web-server/ # Express web server (heavily modularized)
@@ -636,4 +638,5 @@ tests/
- [System Architecture](./system-architecture/index.md) - High-level architecture diagrams
- [Project Roadmap](./project-roadmap.md) - Modularization phases and future work
- [WebSearch](./websearch.md) - WebSearch feature documentation
- [Image Analysis](./image-analysis.md) - First-class ImageAnalysis runtime documentation
- [CLAUDE.md](../CLAUDE.md) - AI-facing development guidance
+15 -2
View File
@@ -43,13 +43,26 @@ ccs cursor auth --manual --token <token> --machine-id <machine-id>
ccs cursor start
```
### 4) Verify status
### 4) Run Cursor-backed Claude
```bash
ccs cursor "explain this repo"
```
### 5) Verify status
```bash
ccs cursor status
```
### 5) Stop daemon
Use `ccs cursor` with bare or normal Claude args to run through the local Cursor proxy.
The admin namespace remains available for setup and inspection:
```bash
ccs cursor help
```
### 6) Stop daemon
```bash
ccs cursor stop
+108
View File
@@ -0,0 +1,108 @@
# Image Analysis Configuration Guide
CCS provides first-class image and PDF analysis for third-party Claude launches that do not have reliable native vision support.
## How Image Analysis Works
Native Claude accounts keep Anthropic's own vision flow.
Third-party profiles now use a CCS-managed local MCP tool named `ImageAnalysis` when the runtime is available. CCS also appends a short steering hint so Claude prefers that tool over `Read` for local image and PDF files.
If the managed runtime, auth, or proxy path is unavailable, CCS falls back to native `Read` instead of failing the whole launch. The old `Read` hook remains only as a compatibility fallback when it can be installed safely.
## Routing Model
ImageAnalysis requests go straight to the CCS-managed provider route:
```text
Claude -> ccs-image-analysis MCP -> CCS provider route -> /api/provider/<backend>/v1/messages
```
Important:
- CCS does not relay image analysis through Claude Code, another CLI, or a second model wrapper.
- For bridge-backed settings profiles, CCS resolves the backend and provider path before launch.
- CCS avoids leaking a profile's ordinary third-party `ANTHROPIC_BASE_URL` or token into image analysis unless that profile is explicitly using a CLIProxy bridge.
## Profile Behavior
| Profile Type | Image Method |
|--------------|--------------|
| Claude `default` / `account` | Native Claude vision / native `Read` |
| Third-party settings / CLIProxy / Copilot | CCS local `ImageAnalysis` MCP tool when ready |
| Third-party when runtime unavailable | Native `Read` fallback |
## Configuration
Configure via dashboard (`Settings -> Image`) or `~/.ccs/config.yaml`:
```yaml
image_analysis:
enabled: true
timeout: 60
fallback_backend: agy
provider_models:
agy: gemini-3-1-flash-preview
codex: gpt-5.1-codex-mini
ghcp: claude-haiku-4.5
```
Useful commands:
```bash
ccs config image-analysis
ccs config image-analysis --enable
ccs config image-analysis --disable
ccs config image-analysis --set-fallback agy
ccs config image-analysis --set-profile-backend glm agy
ccs config image-analysis --clear-profile-backend glm
```
## Prompt Templates
CCS installs editable prompt templates at:
```text
~/.ccs/prompts/image-analysis/
```
Templates:
- `default.txt`
- `screenshot.txt`
- `document.txt`
CCS automatically selects `screenshot` for screenshot-like filenames, `document` for PDFs, and `default` otherwise.
## Runtime Environment
Key runtime env vars:
| Variable | Purpose |
|----------|---------|
| `CCS_IMAGE_ANALYSIS_SKIP` | Disable image analysis for the current launch |
| `CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL` | Explicit CCS runtime base URL |
| `CCS_IMAGE_ANALYSIS_RUNTIME_PATH` | Provider route such as `/api/provider/agy` |
| `CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY` | Explicit CCS runtime auth key |
| `CCS_IMAGE_ANALYSIS_MODEL` | Force a single image-analysis model |
| `CCS_DEBUG` | Verbose runtime logging |
## Troubleshooting
### Claude still uses `Read`
- Confirm `ccs config image-analysis` shows `enabled: true`
- Check the active profile resolves to a configured backend
- Run with `CCS_DEBUG=1` to see runtime preparation details
### ImageAnalysis is not exposed
- Verify CLIProxy auth for the resolved backend
- Verify the local or remote CLIProxy target is reachable
- Check `~/.claude.json` and inherited account configs for `ccs-image-analysis`
### I need to prove requests are going directly to the provider route
Run with `CCS_DEBUG=1` and inspect the resolved runtime path. The request target should be provider-scoped, for example:
```text
/api/provider/agy/v1/messages
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

+198
View File
@@ -0,0 +1,198 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Issue 900 UI Evidence</title>
<style>
:root {
color-scheme: light;
--bg: #f5efe7;
--panel: #fffaf5;
--ink: #231f1c;
--muted: #655c55;
--line: #ddcfc0;
--before: #ff3b30;
--after: #00b894;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 32px;
font-family:
"IBM Plex Sans",
"Avenir Next",
system-ui,
sans-serif;
background: linear-gradient(180deg, #f7f0e7 0%, #efe7dc 100%);
color: var(--ink);
}
main {
max-width: 1300px;
margin: 0 auto;
display: grid;
gap: 24px;
}
.hero,
.section {
background: rgba(255, 250, 245, 0.92);
border: 1px solid var(--line);
border-radius: 24px;
padding: 24px;
box-shadow: 0 18px 40px rgba(35, 31, 28, 0.08);
}
h1,
h2,
p {
margin: 0;
}
.hero {
display: grid;
gap: 12px;
}
.eyebrow {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
}
.hero p,
.section p {
color: var(--muted);
line-height: 1.5;
}
.comparison {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
margin-top: 20px;
}
.card {
border: 1px solid var(--line);
border-radius: 18px;
overflow: hidden;
background: #fff;
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
font-weight: 700;
}
.swatch {
width: 12px;
height: 12px;
border-radius: 999px;
flex: 0 0 auto;
}
.swatch.before {
background: var(--before);
}
.swatch.after {
background: var(--after);
}
img {
display: block;
width: 100%;
height: auto;
}
.notes {
display: grid;
gap: 8px;
margin-top: 16px;
font-size: 15px;
}
code {
font-family:
"IBM Plex Mono",
ui-monospace,
monospace;
font-size: 13px;
background: rgba(35, 31, 28, 0.06);
padding: 2px 6px;
border-radius: 8px;
}
@media (max-width: 900px) {
body {
padding: 20px;
}
.comparison {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main>
<section class="hero">
<p class="eyebrow">Issue 900</p>
<h1>CLIProxy Control Panel Embed Fix</h1>
<p>
Before the fix, the embedded upstream management center landed on
<code>#/login</code> and asked for credentials again. After the fix, the same surface
boots straight into the authenticated dashboard and starts reading
<code>/api/cliproxy-local/v0/management/*</code>.
</p>
</section>
<section class="section">
<h2>Embedded Control Panel</h2>
<p>
Captures use the same route and light theme. The colored callout box marks the iframe
review area.
</p>
<div class="comparison">
<article class="card">
<div class="card-header">
<span class="swatch before"></span>
Before: iframe falls back to login
</div>
<img src="./control-panel-before-annotated.png" alt="Before fix screenshot" />
</article>
<article class="card">
<div class="card-header">
<span class="swatch after"></span>
After: iframe opens management dashboard
</div>
<img src="./control-panel-after-annotated.png" alt="After fix screenshot" />
</article>
</div>
<div class="notes">
<p>Broken state: iframe URL ended at <code>/management.html#/login</code>.</p>
<p>Fixed state: iframe URL ends at <code>/management.html#/</code>.</p>
<p>
Fixed build verified live against the patched worktree dashboard on
<code>localhost:3001</code>.
</p>
</div>
</section>
</main>
</body>
</html>
+19 -10
View File
@@ -1,16 +1,16 @@
# CCS Product Development Requirements (PDR)
Last Updated: 2026-03-24
Last Updated: 2026-04-02
## Product Overview
**Product Name**: CCS (Claude Code Switch)
**Tagline**: The universal AI profile manager for Claude Code
**Tagline**: The multi-provider profile and runtime manager for Claude Code and compatible CLIs
**Description**: CLI wrapper enabling seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, OpenRouter, Qwen, Kimi, DeepSeek) with a React-based dashboard for configuration management. Supports both local and remote CLIProxyAPI instances, hybrid quota management, and official Claude channel runtime setup for Telegram, Discord, and iMessage.
**Description**: Multi-provider CLI/runtime manager enabling seamless switching between multiple Claude accounts, OAuth/API providers, and alternate targets such as Claude Code, Factory Droid, and Codex CLI. Includes a React-based dashboard for configuration management, plus support for local and remote CLIProxyAPI instances, hybrid quota management, and official Claude channel runtime setup for Telegram, Discord, and iMessage.
**Current Version**: v7.34.x (Image Analysis Hook + Performance Improvements)
**Current Version**: v7.34.x+ (First-class ImageAnalysis MCP tooling, WebSearch MCP, performance improvements)
---
@@ -36,8 +36,9 @@ CCS provides:
4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API
5. **Visual Dashboard**: React SPA for configuration management
6. **Automatic WebSearch**: First-class local WebSearch tool with deterministic provider chain for third-party providers
7. **Usage Analytics**: Token tracking, cost analysis, model breakdown
8. **Official Claude Channels**: Runtime auto-enable plus dashboard token/config flow for Telegram, Discord, and macOS-only iMessage
7. **Automatic Image Analysis**: First-class local ImageAnalysis tool with direct provider routing for third-party profiles
8. **Usage Analytics**: Token tracking, cost analysis, model breakdown
9. **Official Claude Channels**: Runtime auto-enable plus dashboard token/config flow for Telegram, Discord, and macOS-only iMessage
---
@@ -99,6 +100,13 @@ CCS provides:
- Keep Gemini CLI, OpenCode, and Grok as optional legacy fallback
- Graceful fallback chain
### FR-007A: First-Class Image Analysis
- Expose a CCS-managed local `ImageAnalysis` MCP tool for third-party profiles that need provider-backed vision
- Resolve the provider route before launch and send requests directly to `/api/provider/<backend>/v1/messages`
- Use editable prompt templates for `default`, `screenshot`, and `document` analysis modes
- Keep the old `Read` hook as compatibility fallback only, not the primary user experience
- Fall back to native `Read` without failing the whole launch when the managed runtime is unavailable
### FR-008: Remote CLIProxy Support
- Connect to remote CLIProxyAPI instances
- CLI flags for proxy configuration (--proxy-host, --proxy-port, etc.)
@@ -277,10 +285,11 @@ CCS provides:
- [x] Entrypoint with privilege dropping
### v7.34 Release (Complete)
- [x] Image Analysis Hook for vision model proxying
- [x] Auto-injection for agy, gemini, codex, cliproxy profiles
- [x] Skip hook for Claude Sub accounts (native vision)
- [x] CLIProxy fallback with deprecated block-image-read
- [x] First-class `ImageAnalysis` MCP tool for third-party launches
- [x] Direct provider-scoped routing for image analysis requests
- [x] Prompt template selection for default / screenshot / document flows
- [x] Hook fallback retained only for compatibility
- [x] Non-fatal native `Read` fallback when managed runtime is unavailable
- [x] `ccs config image-analysis` CLI command
- [x] Doctor integration for hook validation
- [x] 791-line E2E test suite for image analysis
+4 -1
View File
@@ -1,6 +1,6 @@
# CCS Project Roadmap
Last Updated: 2026-04-01
Last Updated: 2026-04-04
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
@@ -41,6 +41,9 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes
- **2026-04-04**: The GitHub README was reduced from a wall-of-text reference dump into a shorter conversion surface that keeps the hero, proof screenshots, and fast-start commands while delegating deeper installation, provider, feature, and CLI-reference content to `docs.ccs.kaitran.ca`. The docs site now includes a dedicated `Product Tour` page for the screenshot-led walkthrough.
- **2026-04-03**: CCS CLI help and completion UX was refreshed. Root help is now shorter and task-oriented, `ccs help <topic|command>` routes to topic-aware help, and shell completions now delegate to the hidden `ccs __complete` backend.
- **2026-04-02**: Third-party image and PDF analysis now follows the same first-class local-tool model as WebSearch. CCS provisions `ccs-image-analysis` as a managed MCP tool, routes requests directly to provider-scoped CCS endpoints such as `/api/provider/agy/v1/messages`, keeps editable prompt templates under `~/.ccs/prompts/image-analysis/`, and demotes the old `Read` hook to a best-effort compatibility fallback. Launches now stay non-fatal and fall back to native `Read` when the managed runtime cannot be prepared.
- **2026-04-01**: The `Compatible -> Codex CLI` dashboard now exposes manual long-context controls for `model_context_window` and `model_auto_compact_token_limit`. CCS reads and patches those upstream Codex config keys directly, adds official guidance that GPT-5.4 long context is experimental and opt-in, and keeps the behavior manual-only so the dashboard never auto-fills or auto-saves long-context values for the user.
- **2026-03-30**: **#862** Third-party WebSearch now uses a first-class CCS-managed MCP tool path instead of relying on a denied native Anthropic `WebSearch` call as the normal UX. CCS provisions `ccs-websearch` into `~/.claude.json`, syncs it into isolated account configs when needed, suppresses native `WebSearch` on third-party launches, preserves the provider order `Exa -> Tavily -> Brave -> DuckDuckGo -> legacy CLI fallback`, and keeps the old hook runtime only as shared provider plumbing plus compatibility fallback. Uninstall cleanup now also removes the managed WebSearch MCP runtime.
- **2026-03-28**: **#773** CCS now ships a dedicated `Compatible -> Codex CLI` dashboard route with a real split-view control center. The page detects the local Codex binary, keeps overview/docs guidance, and adds guided editors for the user-owned `~/.codex/config.toml` layer: top-level runtime defaults, project trust, profiles, model providers, MCP servers, and supported feature flags. Structured saves intentionally normalize TOML formatting and drop comments, so the raw editor remains the fidelity escape hatch. Follow-up fixes added immediate raw snapshot refresh, refresh/discard recovery for stale raw drafts, dirty raw-editor guarding for structured controls, project-trust path validation, read-only handling for unreadable config files, preservation of unsupported upstream values such as granular `approval_policy`, and feature reset-to-default support. CCS still warns that transient runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` may change effective behavior without persisting into the file.
+38 -1
View File
@@ -1,6 +1,6 @@
# Session Sharing Technical Analysis
Last Updated: 2026-02-26
Last Updated: 2026-04-04
## Summary
@@ -69,6 +69,30 @@ Behavior:
- Reuses `CLAUDE_CONFIG_DIR` from mapped account profile after normal account context policy resolution
- Invalid/missing mapped accounts are skipped safely
### Resume Lane Note
Resume follows the active `CLAUDE_CONFIG_DIR`, not just the continuity group:
- plain `ccs -r` resumes the lane plain `ccs` is using right now
- `ccs <account> -r` resumes only that account lane
- those two commands can point at different continuity inventories
That means `shared + deeper` on an account does **not** automatically make old plain-`ccs` resume history appear inside `ccs <account> -r`.
If you want future plain `ccs` sessions to use an account lane, either:
```bash
ccs auth default work
```
or map the default profile explicitly:
```yaml
continuity:
inherit_from_account:
default: work
```
## User Workflows
### New account with shared context
@@ -88,6 +112,19 @@ ccs auth create backup2 --context-group sprint-a --deeper-continuity
No account recreation required for this workflow.
### Backup Before Changing Sync
CCS can back up local continuity artifacts before you change settings:
```bash
ccs auth backup work
ccs auth backup default
```
- `ccs auth backup work` backs up the selected account lane
- `ccs auth backup default` backs up the lane plain `ccs` would use right now
- this is a local continuity backup, not a guaranteed export of all upstream Claude-hosted resume state
## Current Limitations
- Shared context is local filesystem sharing. It does not bypass remote provider permission models.
+1 -1
View File
@@ -8,7 +8,7 @@ High-level architecture overview for the CCS (Claude Code Switch) system.
## System Overview
CCS is a CLI wrapper that enables seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, Kiro, GitHub Copilot, OpenRouter, Qwen, Kimi, DeepSeek). It now supports multiple CLI targets (Claude Code, Factory Droid, Codex CLI) for credential delivery.
CCS is a multi-provider profile and runtime manager that enables seamless switching between multiple Claude accounts, alternative AI providers, and multiple CLI targets (Claude Code, Factory Droid, Codex CLI) for credential delivery.
The system consists of two main components:
+17 -17
View File
@@ -338,7 +338,7 @@ function selectBestAccount(accounts: AccountInfo[]): AccountInfo | null {
| OAuth - Authorization Code Flow (Port-based) |
+===========================================================================+
1. User runs: ccs gemini
1. User runs: ccs codex
|
v
2. Check token cache (~/.ccs/cliproxy/auth/)
@@ -537,29 +537,29 @@ Image Analysis Hook enables vision model proxying through CLIProxy with automati
Claude CLI processes image request
|
v
Hook intercepts image request
Claude prefers ImageAnalysis MCP tool
|
v
Vision Model Proxying (via CLIProxyAPI)
CCS provider-backed image analysis
|
+---> Gemini, Codex, AGY support vision
+---> Provider route resolved before launch
|
+---> Kiro (Claude native vision)
+---> Direct request to /api/provider/<backend>/v1/messages
|
+---> Skip for Claude Sub accounts (native vision)
+---> Native Read fallback if runtime/auth/proxy is unavailable
|
v
Vision response returned to Claude CLI
Text description returned to Claude CLI
```
### Hook Environment
### Runtime Environment
```typescript
// getImageAnalysisHookEnv()
{
ANTHROPIC_IMAGE_HOOK_URL: 'http://localhost:8317/api/image-analysis',
// or for remote proxy:
ANTHROPIC_IMAGE_HOOK_URL: 'https://proxy.example.com:8317/api/image-analysis',
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: 'http://127.0.0.1:8317',
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy',
CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: 'ccs-internal-managed',
}
```
@@ -567,12 +567,12 @@ Image Analysis Hook enables vision model proxying through CLIProxy with automati
| Provider | Vision Support | Notes |
|----------|---|---|
| Gemini | ✓ | Via CLIProxy image analysis |
| Codex | ✓ | Via CLIProxy image analysis |
| Antigravity | ✓ | Via CLIProxy image analysis |
| Kiro | ✓ | Native Claude vision (no proxy needed) |
| Copilot | ✗ | Not supported |
| GLM/Kimi | ✗ | Requires direct API implementation |
| Gemini | ✓ | Via CCS ImageAnalysis provider route |
| Codex | ✓ | Via CCS ImageAnalysis provider route |
| Antigravity | ✓ | Via CCS ImageAnalysis provider route |
| Kiro | ✓ | Via mapped CCS provider route when configured |
| Copilot | ✓ | Via mapped ghcp provider route |
| GLM/Kimi | ✓ | Via explicit or fallback backend mapping |
---
+3 -3
View File
@@ -248,7 +248,7 @@ export WEBSEARCH_HOOK_ENV=... # Image analysis, websearch
```bash
# Direct invocation
ccs gemini
ccs codex
→ claude "args..."
with ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN set
@@ -368,8 +368,8 @@ export class DroidAdapter implements TargetAdapter {
```bash
# Direct invocation
ccs gemini
→ droid -m custom:ccs-gemini "args..."
ccs codex
→ droid -m custom:ccs-codex "args..."
(credentials loaded from ~/.factory/settings.json)
# With --target override
+2 -1
View File
@@ -1,6 +1,6 @@
# WebSearch Configuration Guide
Last Updated: 2026-03-30
Last Updated: 2026-04-04
CCS provides automatic web search for third-party profiles that cannot access Anthropic's native WebSearch API.
@@ -177,6 +177,7 @@ Queries are fingerprinted (`queryHash`, `queryLength`) instead of logged raw by
2. Keep DuckDuckGo enabled unless you have a strong reason to disable it
3. If using Exa, Tavily, or Brave, verify the matching API key
4. Run with `CCS_DEBUG=1` for runtime logs, or `CCS_WEBSEARCH_TRACE=1` for correlated launch/MCP/provider traces
5. If DuckDuckGo returns a non-result HTML error, retry later or enable another provider. CCS now treats that as a provider failure instead of a false empty result.
## Security Considerations
+469
View File
@@ -0,0 +1,469 @@
const fs = require('fs');
const http = require('http');
const https = require('https');
const path = require('path');
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.bmp', '.tiff'];
const PDF_EXTENSIONS = ['.pdf'];
const DEFAULT_MODEL = 'gemini-2.5-flash';
const DEFAULT_TIMEOUT_SEC = 60;
const MAX_FILE_SIZE_MB = 10;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
const MAX_PROMPT_TEMPLATE_BYTES = 32 * 1024;
const SCREENSHOT_NAME_REGEX =
/(screen[-_ ]?shot|screen[-_ ]?capture|screencap|snapshot|snip|clip|capture)/i;
const TEMPLATE_FILE_NAMES = {
default: 'default.txt',
screenshot: 'screenshot.txt',
document: 'document.txt',
};
const FALLBACK_PROMPTS = {
default: `Analyze this image/document thoroughly and provide a detailed description.
Include:
1. Overall content and purpose
2. Text content (if any) - transcribe important text verbatim
3. Visual elements (diagrams, charts, UI components, icons)
4. Layout and structure (sections, hierarchy, flow)
5. Colors, styling, notable design elements
6. Any actionable information (buttons, links, code snippets)
Be comprehensive - this description replaces direct visual access.
The AI assistant reading this cannot see the original image.`,
screenshot: `Analyze this screenshot in detail for a developer who cannot see it.
Focus on:
1. Application/website type and state
2. UI elements visible (buttons, inputs, menus, modals)
3. All text content - transcribe exactly
4. Error messages or notifications (quote exactly)
5. Layout and component hierarchy
6. Interactive elements and their states
7. Console output or logs if visible
8. Any code snippets shown
Be precise - this enables the assistant to help debug or understand the UI.`,
document: `Analyze this document/PDF thoroughly for a developer.
Extract and provide:
1. Document title, type, and structure
2. All text content - transcribe in reading order
3. Tables - format as markdown tables
4. Lists and bullet points - preserve structure
5. Code blocks or technical content
6. Diagrams or flowcharts - describe in detail
7. Headers and section organization
8. Any important metadata visible
Accuracy in text extraction is critical.`,
};
function debugLog(message, data = {}) {
if (!process.env.CCS_DEBUG) return;
const lines = [`[CCS Hook] ${message}`];
for (const [key, value] of Object.entries(data)) {
if (value !== undefined && value !== null && value !== '') {
lines.push(` ${key}: ${value}`);
}
}
console.error(lines.join('\n'));
}
function parseProviderModels(envValue) {
if (!envValue) return {};
return envValue.split(',').reduce((acc, pair) => {
const [provider, ...modelParts] = pair.split(':');
const model = modelParts.join(':').trim();
if (provider && model) {
acc[provider.trim()] = model;
}
return acc;
}, {});
}
function normalizeTemplateName(value) {
if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase();
return Object.prototype.hasOwnProperty.call(TEMPLATE_FILE_NAMES, normalized) ? normalized : null;
}
function selectPromptTemplate(filePath, requestedTemplate) {
const explicitTemplate = normalizeTemplateName(requestedTemplate);
if (explicitTemplate) {
return explicitTemplate;
}
const extension = path.extname(filePath).toLowerCase();
if (PDF_EXTENSIONS.includes(extension)) {
return 'document';
}
return SCREENSHOT_NAME_REGEX.test(path.basename(filePath)) ? 'screenshot' : 'default';
}
function readPromptFile(filePath) {
try {
const stats = fs.statSync(filePath);
if (stats.size > MAX_PROMPT_TEMPLATE_BYTES) {
return null;
}
const content = fs.readFileSync(filePath, 'utf8').trim();
return content.length > 0 ? content : null;
} catch {
return null;
}
}
function loadPromptTemplate(filePath, requestedTemplate, focus) {
const template = selectPromptTemplate(filePath, requestedTemplate);
const promptsDir = process.env.CCS_IMAGE_ANALYSIS_PROMPTS_DIR || '';
const promptPath = promptsDir
? path.join(promptsDir, TEMPLATE_FILE_NAMES[template])
: null;
const promptText = (promptPath && readPromptFile(promptPath)) || FALLBACK_PROMPTS[template];
if (!focus || !focus.trim()) {
return {
template,
promptSource: promptPath ? 'installed-or-fallback' : 'bundled-fallback',
prompt: promptText,
};
}
return {
template,
promptSource: promptPath ? 'installed-or-fallback' : 'bundled-fallback',
prompt: `${promptText}\n\nSpecific focus:\n${focus.trim()}`,
};
}
function getCurrentProvider() {
return process.env.CCS_CURRENT_PROVIDER || '';
}
function getConfiguredModel() {
const explicitModel = process.env.CCS_IMAGE_ANALYSIS_MODEL;
if (explicitModel && explicitModel.trim()) {
return explicitModel.trim();
}
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
return providerModels[getCurrentProvider()] || DEFAULT_MODEL;
}
function getModelsToTry() {
const models = [];
const seen = new Set();
const explicitModel = process.env.CCS_IMAGE_ANALYSIS_MODEL;
if (explicitModel && explicitModel.trim()) {
models.push(explicitModel.trim());
seen.add(explicitModel.trim());
}
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
const providerModel = providerModels[getCurrentProvider()];
if (providerModel && !seen.has(providerModel)) {
models.push(providerModel);
seen.add(providerModel);
}
if (models.length === 0) {
models.push(DEFAULT_MODEL);
}
return models;
}
function getRuntimeBaseUrl() {
const runtimePath = (process.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH || '')
.trim()
.replace(/\/+$/, '');
const explicitBaseUrl = process.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL;
if (explicitBaseUrl && explicitBaseUrl.trim()) {
const normalizedBaseUrl = explicitBaseUrl.trim().replace(/\/+$/, '');
if (!runtimePath) {
return normalizedBaseUrl;
}
try {
const parsed = new URL(normalizedBaseUrl);
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
if (normalizedPath === runtimePath) {
return normalizedBaseUrl;
}
parsed.pathname = runtimePath;
return parsed.toString().replace(/\/+$/, '');
} catch {
return `${normalizedBaseUrl}${runtimePath}`;
}
}
const port = Number.parseInt(process.env.CCS_CLIPROXY_PORT || '8317', 10);
return `http://127.0.0.1:${port}${runtimePath}`;
}
function getRuntimeEndpoint() {
return `${getRuntimeBaseUrl()}/v1/messages`;
}
function getApiKey() {
if (Object.prototype.hasOwnProperty.call(process.env, 'CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY')) {
const explicitApiKey = (process.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY || '').trim();
return explicitApiKey || 'ccs-internal-managed';
}
return process.env.CCS_CLIPROXY_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || 'ccs-internal-managed';
}
function shouldAllowSelfSigned() {
const value = `${process.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED || ''}`.trim().toLowerCase();
return value === '1' || value === 'true' || value === 'yes';
}
function getTimeoutMs(timeoutMs) {
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
return timeoutMs;
}
const timeoutSec = Number.parseInt(
process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || `${DEFAULT_TIMEOUT_SEC}`,
10
);
return Math.max(1, Math.min(600, timeoutSec)) * 1000;
}
function isAnalyzableFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
return IMAGE_EXTENSIONS.includes(ext) || PDF_EXTENSIONS.includes(ext);
}
function getMediaType(filePath) {
const ext = path.extname(filePath).toLowerCase();
return (
{
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.heic': 'image/heic',
'.bmp': 'image/bmp',
'.tiff': 'image/tiff',
'.pdf': 'application/pdf',
}[ext] || 'application/octet-stream'
);
}
function encodeFileToBase64(filePath) {
return fs.readFileSync(filePath).toString('base64');
}
function buildContentBlock(base64Data, mediaType) {
const source = {
type: 'base64',
media_type: mediaType,
data: base64Data,
};
if (mediaType === 'application/pdf') {
return {
type: 'document',
source,
};
}
return {
type: 'image',
source,
};
}
function extractTextContent(response) {
if (!response || !Array.isArray(response.content)) {
return null;
}
const textBlocks = response.content
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text)
.filter((text) => text.trim());
return textBlocks.length > 0 ? textBlocks.join('\n\n') : null;
}
function parseCliProxyResponse(data) {
const response = JSON.parse(data);
const text = extractTextContent(response);
if (!text) {
throw new Error('No text content in response');
}
return text;
}
function analyzeViaCliProxy(base64Data, mediaType, model, prompt, timeoutMs) {
return new Promise((resolve, reject) => {
const endpoint = new URL(getRuntimeEndpoint());
const transport = endpoint.protocol === 'https:' ? https : http;
const apiKey = getApiKey();
const requestBody = JSON.stringify({
model,
max_tokens: 4096,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
buildContentBlock(base64Data, mediaType),
],
},
],
});
const req = transport.request(
{
protocol: endpoint.protocol,
hostname: endpoint.hostname,
port: endpoint.port,
path: `${endpoint.pathname}${endpoint.search}`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(requestBody),
'x-api-key': apiKey,
Authorization: `Bearer ${apiKey}`,
},
timeout: timeoutMs,
...(endpoint.protocol === 'https:' && shouldAllowSelfSigned()
? { rejectUnauthorized: false }
: {}),
},
(res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode === 401 || res.statusCode === 403) {
reject(new Error(`AUTH_ERROR:${res.statusCode}`));
return;
}
if (res.statusCode === 429) {
reject(new Error(`RATE_LIMIT:${res.headers['retry-after'] || ''}`));
return;
}
if (res.statusCode !== 200) {
reject(new Error(`API_ERROR:${res.statusCode}:${data}`));
return;
}
try {
resolve(parseCliProxyResponse(data));
} catch (error) {
reject(error);
}
});
}
);
req.on('error', (error) => reject(error));
req.on('timeout', () => {
req.destroy();
reject(new Error('TIMEOUT'));
});
req.write(requestBody);
req.end();
});
}
async function analyzeWithRetry(base64Data, mediaType, prompt, timeoutMs) {
const models = getModelsToTry();
let lastError = null;
for (const [index, model] of models.entries()) {
try {
debugLog(`Trying model ${index + 1}/${models.length}`, { model });
const description = await analyzeViaCliProxy(base64Data, mediaType, model, prompt, timeoutMs);
return { description, model };
} catch (error) {
lastError = error;
const message = error.message || '';
if (
index === models.length - 1 ||
['AUTH_ERROR', 'RATE_LIMIT', 'TIMEOUT', 'EACCES', 'EPERM', 'ECONNREFUSED'].some((token) =>
message.includes(token)
)
) {
throw error;
}
}
}
throw lastError || new Error('No models configured for image analysis');
}
async function analyzeFile(filePath, options = {}) {
const stats = fs.statSync(filePath);
if (stats.size >= MAX_FILE_SIZE_BYTES) {
throw new Error(`FILE_TOO_LARGE:${stats.size}`);
}
const timeoutMs = getTimeoutMs(options.timeoutMs);
const { template, prompt, promptSource } = loadPromptTemplate(
filePath,
options.template,
options.focus
);
const model = getConfiguredModel();
debugLog('Starting image analysis', {
file: path.basename(filePath),
size: `${(stats.size / 1024).toFixed(1)} KB`,
provider: getCurrentProvider() || 'unknown',
model,
modelsToTry: getModelsToTry().join(' -> '),
timeout: `${timeoutMs / 1000}s`,
endpoint: getRuntimeEndpoint(),
template,
promptSource,
});
const base64Data = encodeFileToBase64(filePath);
const mediaType = getMediaType(filePath);
debugLog('File encoded', {
mediaType,
base64Length: `${(base64Data.length / 1024).toFixed(1)}KB`,
});
const result = await analyzeWithRetry(base64Data, mediaType, prompt, timeoutMs);
debugLog('Analysis complete', {
responseLength: `${result.description.length} chars`,
model: result.model,
template,
});
return {
description: result.description,
model: result.model,
fileSize: stats.size,
mediaType,
template,
};
}
module.exports = {
DEFAULT_MODEL,
DEFAULT_TIMEOUT_SEC,
MAX_FILE_SIZE_BYTES,
analyzeFile,
getRuntimeEndpoint,
isAnalyzableFile,
parseProviderModels,
selectPromptTemplate,
};
+27 -418
View File
@@ -24,7 +24,7 @@
const fs = require('fs');
const path = require('path');
const http = require('http');
const { analyzeFile, isAnalyzableFile, parseProviderModels } = require('./image-analysis-runtime.cjs');
// ============================================================================
// PLATFORM DETECTION
@@ -36,19 +36,8 @@ const isWindows = process.platform === 'win32';
// CONFIGURATION
// ============================================================================
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.bmp', '.tiff'];
const PDF_EXTENSIONS = ['.pdf'];
const DEFAULT_MODEL = 'gemini-2.5-flash';
const DEFAULT_TIMEOUT_SEC = 60;
const MAX_FILE_SIZE_MB = 10;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
const CLIPROXY_HOST = '127.0.0.1';
const CLIPROXY_PORT = parseInt(process.env.CCS_CLIPROXY_PORT || '8317', 10);
const CLIPROXY_PATH = '/v1/messages';
// API key passed via env from cliproxy-executor, defaults to CCS internal key
const CLIPROXY_API_KEY = process.env.CCS_CLIPROXY_API_KEY || 'ccs-internal-managed';
// ============================================================================
// ERROR CODES (for categorization)
@@ -65,23 +54,6 @@ const ERROR_CODES = {
UNKNOWN: 'UNKNOWN',
};
// Default analysis prompt
const DEFAULT_PROMPT = `Analyze this image/document thoroughly and provide a detailed description.
Include:
1. Overall content and purpose
2. Text content (if any) - transcribe important text
3. Visual elements (diagrams, charts, UI components)
4. Layout and structure
5. Colors, styling, notable design elements
6. Any actionable information (buttons, links, code)
Be comprehensive - this description replaces direct visual access.`;
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Output debug information to stderr
* Only outputs when CCS_DEBUG=1
@@ -105,19 +77,19 @@ function debugLog(message, data = {}) {
*/
function getDebugContext(filePath, stats) {
const currentProvider = process.env.CCS_CURRENT_PROVIDER || 'unknown';
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
const model = providerModels[currentProvider] || DEFAULT_MODEL;
const model =
process.env.CCS_IMAGE_ANALYSIS_MODEL ||
parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS)[currentProvider] ||
DEFAULT_MODEL;
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
const modelsToTry = getModelsToTry();
return {
file: path.basename(filePath),
size: stats ? `${(stats.size / 1024).toFixed(1)} KB` : 'unknown',
provider: currentProvider,
model: model,
modelsToTry: modelsToTry.length > 1 ? modelsToTry.join(' -> ') : model,
timeout: `${timeout}s`,
endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}${CLIPROXY_PATH}`,
endpoint: process.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL || '(runtime fallback)',
};
}
@@ -126,319 +98,12 @@ function getDebugContext(filePath, stats) {
*/
function getProviderContext() {
const provider = process.env.CCS_CURRENT_PROVIDER || 'unknown';
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
const model = providerModels[provider] || DEFAULT_MODEL;
const model =
process.env.CCS_IMAGE_ANALYSIS_MODEL ||
parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS)[provider] ||
DEFAULT_MODEL;
return { provider, model };
}
/**
* Parse provider_models env var to object
* Format: provider:model,provider:model
*/
function parseProviderModels(envValue) {
if (!envValue) return {};
const result = {};
envValue.split(',').forEach((pair) => {
const [provider, model] = pair.split(':');
if (provider && model && model.trim()) {
result[provider.trim()] = model.trim();
}
});
return result;
}
/**
* Extract concatenated text content from CLIProxy response blocks.
* Skips thinking and other non-text blocks.
*/
function extractTextContent(response) {
if (!response || !Array.isArray(response.content)) {
return null;
}
const textBlocks = response.content
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text)
.filter((text) => text.trim());
if (textBlocks.length === 0) {
return null;
}
return textBlocks.join('\n\n');
}
/**
* Parse raw CLIProxy response body and extract text content.
*/
function parseCliProxyResponse(data) {
let response;
try {
response = JSON.parse(data);
} catch (err) {
throw new Error(`Failed to parse response: ${err.message}`);
}
const text = extractTextContent(response);
if (!text) {
throw new Error('No text content in response');
}
return text;
}
/**
* Get model for current provider from provider_models mapping
* Returns primary model only (for display/logging)
*/
function getModelForProvider() {
const currentProvider = process.env.CCS_CURRENT_PROVIDER || '';
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
return providerModels[currentProvider] || DEFAULT_MODEL;
}
/**
* Get list of models to try in order:
* 1. provider_models[current_provider] (if exists)
* 2. DEFAULT_MODEL when no provider-specific vision model is configured
*
* ANTHROPIC_MODEL is intentionally ignored here because the chat model may
* not be vision-capable on the current provider route.
*/
function getModelsToTry() {
const currentProvider = process.env.CCS_CURRENT_PROVIDER || '';
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
const models = [];
const seen = new Set();
// 1. Provider-specific model
if (providerModels[currentProvider]) {
models.push(providerModels[currentProvider]);
seen.add(providerModels[currentProvider]);
}
// 2. Default model — only when no provider-specific model is configured,
// since DEFAULT_MODEL (gemini-2.5-flash) may not be routable on the
// current provider's CLIProxy endpoint (e.g. codex, claude)
if (models.length === 0 && !seen.has(DEFAULT_MODEL)) {
models.push(DEFAULT_MODEL);
seen.add(DEFAULT_MODEL);
}
return models;
}
/**
* Analyze with retry logic - tries models in order until one succeeds
*/
async function analyzeWithRetry(base64Data, mediaType, timeoutMs) {
const models = getModelsToTry();
// Defensive check - should never happen but provides clear error
if (models.length === 0) {
throw new Error('No models configured for image analysis');
}
let lastError = null;
for (let i = 0; i < models.length; i++) {
const model = models[i];
try {
debugLog(`Trying model ${i + 1}/${models.length}`, { model });
const result = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs);
if (i > 0) {
debugLog('Retry succeeded', { model, attempt: i + 1 });
}
return { description: result, model };
} catch (err) {
lastError = err;
const isLastModel = i === models.length - 1;
// Don't retry on certain errors (auth, rate limit, timeout, file access, network)
const errMsg = err.message || '';
const noRetryPatterns = [
'AUTH_ERROR', 'RATE_LIMIT', 'TIMEOUT',
'EACCES', 'EPERM', 'ECONNREFUSED',
'ENOTFOUND', 'ENETUNREACH', 'EAI_AGAIN' // Network errors - no point retrying
];
const shouldNotRetry = noRetryPatterns.some(p => errMsg.includes(p));
if (shouldNotRetry || isLastModel) {
debugLog('Analysis failed, no more retries', {
model,
error: errMsg,
reason: shouldNotRetry ? 'non-retryable error' : 'last model'
});
throw err;
}
debugLog('Model failed, trying next', {
model,
error: errMsg.substring(0, 100),
nextModel: models[i + 1]
});
}
}
throw lastError || new Error('No models available');
}
/**
* Check if file is an analyzable image or PDF
*/
function isAnalyzableFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
return IMAGE_EXTENSIONS.includes(ext) || PDF_EXTENSIONS.includes(ext);
}
/**
* Get MIME type from file extension
*/
function getMediaType(filePath) {
const ext = path.extname(filePath).toLowerCase();
const mimeTypes = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.heic': 'image/heic',
'.bmp': 'image/bmp',
'.tiff': 'image/tiff',
'.pdf': 'application/pdf',
};
return mimeTypes[ext] || 'application/octet-stream';
}
/**
* Encode file to base64
*/
function encodeFileToBase64(filePath) {
const content = fs.readFileSync(filePath);
return content.toString('base64');
}
/**
* Check if CLIProxy is available
*/
function isCliProxyAvailable() {
return new Promise((resolve) => {
const req = http.request(
{
hostname: CLIPROXY_HOST,
port: CLIPROXY_PORT,
path: '/',
method: 'GET',
timeout: 2000,
},
(res) => {
resolve(res.statusCode >= 200 && res.statusCode < 500);
}
);
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.end();
});
}
/**
* Analyze file via CLIProxy vision API
*/
function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) {
return new Promise((resolve, reject) => {
const requestBody = JSON.stringify({
model: model,
max_tokens: 4096,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: DEFAULT_PROMPT },
{
type: 'image',
source: {
type: 'base64',
media_type: mediaType,
data: base64Data,
},
},
],
},
],
});
const req = http.request(
{
hostname: CLIPROXY_HOST,
port: CLIPROXY_PORT,
path: CLIPROXY_PATH,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(requestBody),
'x-api-key': CLIPROXY_API_KEY,
},
timeout: timeoutMs,
},
(res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('error', (err) => {
reject(err);
});
res.on('end', () => {
// Categorize by status code
if (res.statusCode === 401 || res.statusCode === 403) {
reject(new Error(`AUTH_ERROR:${res.statusCode}`));
return;
}
if (res.statusCode === 429) {
const retryAfter = res.headers['retry-after'];
reject(new Error(`RATE_LIMIT:${retryAfter || ''}`));
return;
}
if (res.statusCode !== 200) {
reject(new Error(`API_ERROR:${res.statusCode}:${data}`));
return;
}
if (!data || !data.trim()) {
reject(new Error('Empty response from CLIProxy'));
return;
}
try {
const text = parseCliProxyResponse(data);
resolve(text);
} catch (err) {
reject(err);
}
});
}
);
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error('TIMEOUT'));
});
req.write(requestBody);
req.end();
});
}
/**
* Format analysis description for Claude (matches websearch format)
*/
@@ -520,29 +185,6 @@ function outputFileTooLargeError(filePath, actualSizeMB, maxSizeMB) {
process.exit(2);
}
/**
* CLIProxy unavailable error
*/
function outputCliProxyUnavailableError(filePath, endpoint) {
const output = formatErrorOutput(
filePath,
ERROR_CODES.CLIPROXY_UNAVAILABLE,
`CLIProxy not available at ${endpoint}`,
[
'CLIProxy service may not be running',
'Start with: ccs config (opens dashboard, starts CLIProxy)',
'Or manually: ccs cliproxy start',
`Verify: curl ${endpoint}`,
'Check status: ccs doctor',
]
);
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* Authentication error
*/
function outputAuthError(filePath, statusCode) {
const { provider } = getProviderContext();
const output = formatErrorOutput(
@@ -758,10 +400,11 @@ function shouldSkipHook() {
}
// Check if current provider has a vision model configured
const explicitModel = process.env.CCS_IMAGE_ANALYSIS_MODEL;
const currentProvider = process.env.CCS_CURRENT_PROVIDER || '';
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
if (!providerModels[currentProvider]) {
if (!explicitModel?.trim() && !providerModels[currentProvider]) {
debugLog(`Skipping: provider "${currentProvider}" not in provider_models`, {
configured_providers: Object.keys(providerModels).join(', ') || 'none',
});
@@ -832,57 +475,15 @@ async function processHook() {
process.exit(0);
}
// Check if file exists
if (!fs.existsSync(filePath)) {
// Let native Read handle the error
process.exit(0);
}
// Check file size
const stats = fs.statSync(filePath);
if (stats.size >= MAX_FILE_SIZE_BYTES) {
outputFileTooLargeError(filePath, stats.size / 1024 / 1024, MAX_FILE_SIZE_MB);
return;
}
const debugContext = getDebugContext(filePath, null);
debugLog('Image analysis runtime prepared', debugContext);
// Check CLIProxy availability
const cliProxyAvailable = await isCliProxyAvailable();
if (!cliProxyAvailable) {
debugLog('Blocking: CLIProxy not available', {
endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`,
action: 'blocking to prevent context overflow',
});
outputCliProxyUnavailableFallback(filePath);
return;
}
const model = getModelForProvider();
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
const timeoutMs = Math.max(1, Math.min(600, timeout)) * 1000;
// Get debug context before analysis
const debugContext = getDebugContext(filePath, stats);
debugLog('Starting image analysis', debugContext);
// Encode file to base64
const base64Data = encodeFileToBase64(filePath);
const mediaType = getMediaType(filePath);
debugLog('File encoded', {
mediaType: mediaType,
base64Length: `${(base64Data.length / 1024).toFixed(1)}KB`,
});
// Analyze via CLIProxy with retry logic
const { description, model: usedModel } = await analyzeWithRetry(base64Data, mediaType, timeoutMs);
debugLog('Analysis complete', {
responseLength: `${description.length} chars`,
model: usedModel,
});
// Output success
outputSuccess(filePath, description, usedModel, stats.size);
const result = await analyzeFile(filePath);
outputSuccess(filePath, result.description, result.model, result.fileSize);
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[CCS Hook] Error:', err.message);
@@ -893,7 +494,10 @@ async function processHook() {
// Categorize error by message pattern
const errMsg = err.message || '';
if (errMsg.startsWith('AUTH_ERROR:')) {
if (errMsg.startsWith('FILE_TOO_LARGE:')) {
const fileSizeMb = Number.parseInt(errMsg.split(':')[1], 10) / 1024 / 1024;
outputFileTooLargeError(filePath, fileSizeMb, 10);
} else if (errMsg.startsWith('AUTH_ERROR:')) {
const statusCode = parseInt(errMsg.split(':')[1], 10);
outputAuthError(filePath, statusCode);
} else if (errMsg.startsWith('RATE_LIMIT:')) {
@@ -907,8 +511,13 @@ async function processHook() {
} else if (errMsg === 'TIMEOUT' || errMsg.includes('timed out') || errMsg.includes('timeout')) {
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
outputTimeoutError(filePath, timeout);
} else if (errMsg.includes('ECONNREFUSED') || errMsg.includes('ENOTFOUND')) {
outputCliProxyUnavailableError(filePath, `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`);
} else if (
errMsg.includes('ECONNREFUSED') ||
errMsg.includes('ENOTFOUND') ||
errMsg.includes('ENETUNREACH') ||
errMsg.includes('EAI_AGAIN')
) {
outputCliProxyUnavailableFallback(filePath);
} else if (errMsg.includes('EACCES') || errMsg.includes('EPERM')) {
outputFileAccessError(filePath, errMsg);
} else {
+37 -2
View File
@@ -67,6 +67,8 @@ const PROVIDER_CONFIG = {
const ddgLinkRe = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
const ddgSnippetRe = /<a class="result__snippet[^"]*".*?>([\s\S]*?)<\/a>/g;
const ddgNoResultsRe = /class=['"][^'"]*no-results(?:__message)?[^'"]*['"]/i;
const ddgNoResultsHeadingRe = /No results found for/i;
const htmlTagRe = /<[^>]+>/g;
function debug(message) {
@@ -427,6 +429,30 @@ function extractDuckDuckGoResults(html, count) {
});
}
function classifyDuckDuckGoHtml(html, count) {
const responseHtml = String(html || '');
const results = extractDuckDuckGoResults(responseHtml, count);
if (results.length > 0) {
return {
kind: 'results',
results,
};
}
if (ddgNoResultsRe.test(responseHtml) || ddgNoResultsHeadingRe.test(responseHtml)) {
return {
kind: 'no_results',
results: [],
};
}
return {
kind: 'non_result_html',
results: [],
error: 'DuckDuckGo returned non-result HTML response (possible anti-bot/challenge page)',
};
}
function formatStructuredSearchResults(query, providerName, results) {
const lines = [
'CCS local WebSearch evidence',
@@ -680,10 +706,18 @@ async function tryDuckDuckGoSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
}
const html = await response.text();
const results = extractDuckDuckGoResults(html, getResultCount('duckduckgo'));
const parsed = classifyDuckDuckGoHtml(html, getResultCount('duckduckgo'));
if (parsed.kind === 'non_result_html') {
return {
success: false,
error: `${parsed.error} (status ${response.status})`,
statusCode: response.status,
};
}
return {
success: true,
content: formatStructuredSearchResults(query, 'DuckDuckGo', results),
content: formatStructuredSearchResults(query, 'DuckDuckGo', parsed.results),
};
} catch (error) {
return {
@@ -1229,6 +1263,7 @@ if (require.main === module) {
module.exports = {
buildFailureHookOutput,
buildSuccessHookOutput,
classifyDuckDuckGoHtml,
extractDuckDuckGoResults,
formatStructuredSearchResults,
getActiveProviders,
+440
View File
@@ -0,0 +1,440 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
function loadRuntimeModule() {
const candidates = [
path.join(__dirname, 'image-analysis-runtime.cjs'),
path.join(__dirname, '../hooks/image-analysis-runtime.cjs'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return require(candidate);
}
}
throw new Error(
`ccs-image-analysis runtime not found. Checked: ${candidates.map((candidate) => path.basename(candidate)).join(', ')}`
);
}
const { analyzeFile, isAnalyzableFile } = loadRuntimeModule();
const PROTOCOL_VERSION = '2024-11-05';
const SERVER_NAME = 'ccs-image-analysis';
const SERVER_VERSION = '1.0.0';
const TOOL_NAME = 'ImageAnalysis';
const TOOL_ALIASES = ['AnalyzeImage', 'ReadImage'];
const TEMPLATE_NAMES = ['default', 'screenshot', 'document'];
const TOOL_DESCRIPTION =
'Analyze a local image or PDF file with CCS provider-backed vision. Prefer this tool over Read for image and PDF paths. Use Read for text, code, and other plain files.';
function isSupportedToolName(name) {
return name === TOOL_NAME || TOOL_ALIASES.includes(name);
}
function shouldExposeTools() {
return (
process.env.CCS_IMAGE_ANALYSIS_ENABLED === '1' &&
process.env.CCS_IMAGE_ANALYSIS_SKIP !== '1' &&
Boolean(process.env.CCS_CURRENT_PROVIDER || process.env.CCS_IMAGE_ANALYSIS_MODEL)
);
}
function getTools() {
if (!shouldExposeTools()) {
return [];
}
return [
{
name: TOOL_NAME,
description: TOOL_DESCRIPTION,
inputSchema: {
type: 'object',
properties: {
filePath: {
type: 'string',
description:
'Workspace-relative path, or an absolute path inside the current workspace, to a local image or PDF file to analyze.',
},
focus: {
type: 'string',
description:
'Optional question or area of focus, for example "explain the error dialog" or "transcribe the visible text".',
},
template: {
type: 'string',
enum: TEMPLATE_NAMES,
description:
'Optional prompt template override. Use screenshot for UI captures, document for PDFs/docs, or default for general images.',
},
},
required: ['filePath'],
additionalProperties: false,
},
},
];
}
function writeMessage(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function writeResponse(id, result) {
writeMessage({
jsonrpc: '2.0',
id,
result,
});
}
function writeError(id, code, message) {
writeMessage({
jsonrpc: '2.0',
id,
error: {
code,
message,
},
});
}
function formatResult(filePath, result, focus) {
const lines = [
'[Image Analysis via CCS]',
'',
`File: ${path.basename(filePath)} (${(result.fileSize / 1024).toFixed(1)} KB)`,
`Model: ${result.model}`,
`Template: ${result.template}`,
];
if (focus && focus.trim()) {
lines.push(`Focus: ${focus.trim()}`);
}
lines.push('', '---', '', result.description);
return lines.join('\n');
}
function normalizeTemplate(value) {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim().toLowerCase();
return TEMPLATE_NAMES.includes(normalized) ? normalized : undefined;
}
function normalizePathForComparison(value) {
return process.platform === 'win32' ? value.toLowerCase() : value;
}
function isPathWithinWorkspace(workspaceRoot, candidatePath) {
const relativePath = path.relative(workspaceRoot, candidatePath);
return (
relativePath === '' ||
(!relativePath.startsWith('..') &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath))
);
}
function resolveFilePath(toolArgs) {
if (!toolArgs || typeof toolArgs !== 'object') {
return '';
}
const candidates = [toolArgs.filePath, toolArgs.file_path, toolArgs.path];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate.trim();
}
}
return '';
}
function resolveWorkspaceFilePath(toolArgs) {
const requestedPath = resolveFilePath(toolArgs);
if (!requestedPath) {
return { filePath: '', error: null };
}
const workspaceRoot = (() => {
try {
return fs.realpathSync(process.cwd());
} catch {
return path.resolve(process.cwd());
}
})();
const absolutePath = path.resolve(process.cwd(), requestedPath);
const comparisonPath = (() => {
if (fs.existsSync(absolutePath)) {
return fs.realpathSync(absolutePath);
}
const suffixSegments = [];
let currentPath = absolutePath;
while (!fs.existsSync(currentPath)) {
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) {
break;
}
suffixSegments.unshift(path.basename(currentPath));
currentPath = parentPath;
}
const resolvedExistingPath = fs.existsSync(currentPath)
? fs.realpathSync(currentPath)
: path.resolve(currentPath);
return path.join(resolvedExistingPath, ...suffixSegments);
})();
if (
!isPathWithinWorkspace(
normalizePathForComparison(workspaceRoot),
normalizePathForComparison(comparisonPath)
)
) {
return {
filePath: '',
error: 'ImageAnalysis only allows files inside the current workspace.',
};
}
return {
filePath: absolutePath,
error: null,
};
}
function resolveFocus(toolArgs) {
return typeof toolArgs.focus === 'string' && toolArgs.focus.trim().length > 0
? toolArgs.focus.trim()
: undefined;
}
function formatErrorDetail(filePath, error) {
const message = error instanceof Error ? error.message : String(error);
if (message.startsWith('FILE_TOO_LARGE:')) {
const fileSizeBytes = Number.parseInt(message.split(':')[1], 10);
const sizeMb = Number.isFinite(fileSizeBytes) ? (fileSizeBytes / 1024 / 1024).toFixed(1) : '?';
return `ImageAnalysis cannot process ${path.basename(filePath)} because it is too large (${sizeMb} MB). The limit is 10 MB.`;
}
if (message.startsWith('AUTH_ERROR:')) {
return `ImageAnalysis failed because CCS vision auth for this provider is unavailable (${message.split(':')[1]}).`;
}
if (message.startsWith('RATE_LIMIT:')) {
return `ImageAnalysis hit a provider rate limit while analyzing ${path.basename(filePath)}.`;
}
if (message.startsWith('API_ERROR:')) {
return `ImageAnalysis failed at the CCS provider route while analyzing ${path.basename(filePath)}.`;
}
if (
message === 'TIMEOUT' ||
message.includes('timed out') ||
message.includes('timeout') ||
message.includes('ECONNREFUSED') ||
message.includes('ENOTFOUND') ||
message.includes('ENETUNREACH') ||
message.includes('EAI_AGAIN')
) {
return `ImageAnalysis could not reach the configured CCS provider route for ${path.basename(filePath)}.`;
}
if (message.includes('EACCES') || message.includes('EPERM')) {
return `ImageAnalysis could not read ${filePath} because access was denied.`;
}
return `ImageAnalysis failed for ${path.basename(filePath)}: ${message}`;
}
async function handleToolCall(message) {
const id = message.id;
const params = message.params || {};
const toolArgs = params.arguments || {};
const toolName = params.name || '<missing>';
if (!isSupportedToolName(toolName)) {
writeError(id, -32602, `Unknown tool: ${toolName}`);
return;
}
if (!shouldExposeTools()) {
writeResponse(id, {
content: [
{
type: 'text',
text: 'CCS ImageAnalysis is unavailable for this profile or no provider-backed vision route is ready.',
},
],
isError: true,
});
return;
}
const { filePath, error: filePathError } = resolveWorkspaceFilePath(toolArgs);
if (!filePath) {
writeError(
id,
-32602,
filePathError || `Tool "${TOOL_NAME}" requires a non-empty filePath.`
);
return;
}
if (!fs.existsSync(filePath)) {
writeResponse(id, {
content: [{ type: 'text', text: `ImageAnalysis could not find file: ${filePath}` }],
isError: true,
});
return;
}
if (!isAnalyzableFile(filePath)) {
writeResponse(id, {
content: [
{
type: 'text',
text: `ImageAnalysis only supports image and PDF files. Use Read for ${path.basename(filePath)} instead.`,
},
],
isError: true,
});
return;
}
const focus = resolveFocus(toolArgs);
const template = normalizeTemplate(toolArgs.template);
try {
const result = await analyzeFile(filePath, { focus, template });
writeResponse(id, {
content: [{ type: 'text', text: formatResult(filePath, result, focus) }],
});
} catch (error) {
writeResponse(id, {
content: [{ type: 'text', text: formatErrorDetail(filePath, error) }],
isError: true,
});
}
}
async function handleMessage(message) {
if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
return;
}
switch (message.method) {
case 'initialize':
writeResponse(message.id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: {
tools: {},
},
serverInfo: {
name: SERVER_NAME,
version: SERVER_VERSION,
},
});
return;
case 'notifications/initialized':
return;
case 'ping':
writeResponse(message.id, {});
return;
case 'tools/list':
writeResponse(message.id, { tools: getTools() });
return;
case 'tools/call':
await handleToolCall(message);
return;
default:
if (message.id !== undefined) {
writeError(message.id, -32601, `Method not found: ${message.method}`);
}
}
}
let inputBuffer = Buffer.alloc(0);
function processIncomingBuffer() {
while (true) {
let body;
const startsWithLegacyHeaders = inputBuffer
.slice(0, Math.min(inputBuffer.length, 32))
.toString('utf8')
.toLowerCase()
.startsWith('content-length:');
if (startsWithLegacyHeaders) {
const headerEnd = inputBuffer.indexOf('\r\n\r\n');
if (headerEnd === -1) {
return;
}
const headerText = inputBuffer.slice(0, headerEnd).toString('utf8');
const contentLengthMatch = headerText.match(/content-length:\s*(\d+)/i);
if (!contentLengthMatch) {
inputBuffer = Buffer.alloc(0);
return;
}
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
const messageEnd = headerEnd + 4 + contentLength;
if (inputBuffer.length < messageEnd) {
return;
}
body = inputBuffer.slice(headerEnd + 4, messageEnd).toString('utf8');
inputBuffer = inputBuffer.slice(messageEnd);
} else {
const newlineIndex = inputBuffer.indexOf('\n');
if (newlineIndex === -1) {
return;
}
body = inputBuffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim();
inputBuffer = inputBuffer.slice(newlineIndex + 1);
if (!body) {
continue;
}
}
try {
const message = JSON.parse(body);
Promise.resolve(handleMessage(message)).catch((error) => {
if (message && message.id !== undefined) {
writeError(message.id, -32603, (error && error.message) || 'Internal error');
} else if (process.env.CCS_DEBUG) {
console.error(`[ccs-image-analysis] ${error instanceof Error ? error.stack : error}`);
}
});
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(
`[ccs-image-analysis] Failed to parse JSON-RPC message: ${
error instanceof Error ? error.message : error
}`
);
}
}
}
}
process.stdin.on('data', (chunk) => {
inputBuffer = Buffer.concat([inputBuffer, chunk]);
processIncomingBuffer();
});
process.stdin.on('end', () => {
process.exit(0);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.65.3",
"version": "7.65.3-dev.11",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",
+56 -132
View File
@@ -2,14 +2,16 @@
Tab completion for CCS commands, subcommands, profiles, and flags.
The completion scripts are thin adapters over the hidden `ccs __complete` backend so
all supported shells stay aligned with the same command graph.
**Supported Shells:** Bash, Zsh, Fish, PowerShell
## Features
- Complete profile names (both settings-based and account-based)
- Complete `ccs auth` subcommands (create, list, show, remove, default)
- Complete flags (`--help`, `--version`, `--json`, `--verbose`, `--yes`)
- Complete profile names for auth subcommands
- Complete root commands, help topics, provider shortcuts, and command flags
- Complete `ccs auth` and `ccs api` lifecycle subcommands
- Context-aware: suggests relevant options based on current command
## Quick Install (Recommended)
@@ -32,6 +34,12 @@ ccs --shell-completion --fish # Force fish
ccs --shell-completion --powershell # Force PowerShell
```
**Help and verification:**
```bash
ccs help completion
ccs --shell-completion --force
```
## Manual Installation
Completion files are installed to `~/.ccs/completions/` during `npm install`.
@@ -116,188 +124,104 @@ sudo cp scripts/completion/ccs.fish /usr/share/fish/vendor_completions.d/
```bash
$ ccs <TAB>
auth doctor glm kimi work personal --help --version
auth api cliproxy config doctor docker help
$ ccs auth <TAB>
create list show remove default --help
$ ccs help <TAB>
profiles providers completion targets
```
### Profile Completion
### Context Completion
```bash
$ ccs auth show <TAB>
work personal team --json
$ ccs auth remove <TAB>
work personal team --yes -y
$ ccs api <TAB>
create list discover copy export import remove
```
### Flag Completion
### Backend Contract
```bash
$ ccs auth list <TAB>
--verbose --json
$ ccs auth show work <TAB>
--json
$ ccs __complete --shell bash --current do
doctor
docker
```
## Completion Behavior
### Top-level (after `ccs`)
- Built-in commands: `auth`, `doctor`
- Flags: `--help`, `--version`, `-h`, `-v`
- Settings-based profiles: from `~/.ccs/config.json`
- Account-based profiles: from `~/.ccs/profiles.json`
### After `ccs auth`
- Subcommands: `create`, `list`, `show`, `remove`, `default`
- Flags: `--help`, `-h`
### After `ccs auth <subcommand>`
- **create**: No completion (user enters new profile name)
- Flags: `--force`
- **list**: No profile completion
- Flags: `--verbose`, `--json`
- **show**: Account profiles only
- Flags: `--json`
- **remove**: Account profiles only
- Flags: `--yes`, `-y`
- **default**: Account profiles only
### After `ccs <profile>`
- No completion (Claude CLI arguments are free-form)
Shell adapters now call the shared CCS completion backend instead of maintaining their own
copy of the command graph. That means:
- top-level commands, help topics, and provider shortcuts come from CCS itself
- dynamic profiles and CLIProxy variants resolve through the real config loaders
- bash, zsh, fish, and PowerShell stay aligned with the same completion logic
## Troubleshooting
### Bash: Completion not working
### Bash
1. Check if bash-completion is installed:
```bash
# macOS
brew install bash-completion
# Ubuntu/Debian
sudo apt install bash-completion
```
2. Verify jq is installed (required for profile completion):
```bash
command -v jq
```
3. Check if completion is loaded:
1. Check if completion is loaded:
```bash
complete -p ccs
```
Should output:
```
complete -F _ccs_completion ccs
2. Verify the backend directly:
```bash
ccs __complete --shell bash --current "" -- help
```
### Zsh: Completion not working
### Zsh
1. Verify completion system is enabled in `~/.zshrc`:
1. Verify completion system is enabled:
```zsh
autoload -Uz compinit && compinit
```
2. Check if completion is loaded:
```zsh
which _ccs
```
3. Rebuild completion cache:
2. Rebuild the cache if needed:
```zsh
rm ~/.zcompdump && compinit
```
### PowerShell: Completion not working
1. Check PowerShell version (5.1+ required):
```powershell
$PSVersionTable.PSVersion
3. Verify the backend directly:
```zsh
ccs __complete --shell zsh --current "" -- help
```
2. Verify profile is loaded:
### PowerShell
1. Check that the profile exists:
```powershell
Test-Path $PROFILE
```
3. Check if completion is registered:
2. Verify the backend directly:
```powershell
(Get-ArgumentCompleter).CommandName | Select-String ccs
ccs __complete --shell powershell --current "" -- help
```
### Fish: Completion not working
### Fish
1. Check Fish version (3.0+ required):
```fish
fish --version
```
2. Verify completion file is in the right location:
1. Verify completion file location:
```fish
ls ~/.config/fish/completions/ccs.fish
```
3. Verify jq is installed (required for profile completion):
```fish
which jq
```
4. Test completion manually:
2. Test completion manually:
```fish
complete -C'ccs '
```
5. If needed, rebuild completions:
3. Verify the backend directly:
```fish
fish_update_completions
ccs __complete --shell fish --current "" -- help
```
## Technical Details
### Bash Implementation
- Uses `complete -F` for programmable completion
- Compatible with bash 3.2+ (macOS default)
- Reads profiles dynamically using `jq`
- Context-aware based on `COMP_CWORD` and `COMP_WORDS`
### Zsh Implementation
- Uses `_arguments` and `_describe` for rich completion
- Compatible with zsh 5.0+
- Supports completion descriptions
- Context-aware using `$state` and `$words`
### PowerShell Implementation
- Uses `Register-ArgumentCompleter`
- Compatible with PowerShell 5.1+
- Reads profiles dynamically using `ConvertFrom-Json`
- Provides `CompletionResult` objects
### Fish Implementation
- Uses declarative `complete` command
- Compatible with Fish 3.0+
- Automatic loading from `~/.config/fish/completions/`
- Helper functions for dynamic profile loading
- Context-aware using `__fish_seen_subcommand_from`
- No manual sourcing required
## Dependencies
- **jq**: Required for reading profiles from JSON files
- Install: `brew install jq` (macOS) or `apt install jq` (Ubuntu)
- Already required by CCS core functionality
- Bash uses `complete -F`
- Zsh uses a custom `_ccs` completion function
- Fish uses `complete -a` with backend command substitution
- PowerShell uses `Register-ArgumentCompleter`
- All four shells now delegate suggestion logic to `ccs __complete`
## Contributing
When adding new commands or flags:
1. Update all four completion scripts (bash, zsh, fish, PowerShell)
2. Test on each shell
3. Update this README with new completion examples
4. Maintain cross-shell parity
When adding or changing command surfaces:
1. Update the shared TypeScript command/completion catalog
2. Run `bun run validate`
3. Smoke-check at least one installed shell adapter plus the backend directly
## See Also
+28 -196
View File
@@ -1,210 +1,42 @@
# Bash completion for CCS (Claude Code Switch)
# Compatible with bash 3.2+
#
# Installation:
# Add to ~/.bashrc or ~/.bash_profile:
# source /path/to/ccs/scripts/completion/ccs.bash
#
# Or install system-wide (requires sudo):
# sudo cp scripts/completion/ccs.bash /etc/bash_completion.d/ccs
_ccs_completion() {
local cur prev words cword
local cur
COMPREPLY=()
# Get current word and previous word
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
# Top-level completion (first argument)
if [[ ${COMP_CWORD} -eq 1 ]]; then
local commands="auth api cliproxy config doctor env persist sync update"
local flags="--help --version --shell-completion -h -v -sc"
local cliproxy_profiles="gemini codex agy qwen"
local profiles=""
# Add profiles from config.json (settings-based profiles)
if [[ -f ~/.ccs/config.json ]]; then
profiles="$profiles $(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null || true)"
fi
# Add profiles from profiles.json (account-based profiles)
if [[ -f ~/.ccs/profiles.json ]]; then
profiles="$profiles $(jq -r '.profiles | keys[]' ~/.ccs/profiles.json 2>/dev/null || true)"
fi
# Add cliproxy variants from config.json
if [[ -f ~/.ccs/config.json ]]; then
profiles="$profiles $(jq -r '.cliproxy | keys[]' ~/.ccs/config.json 2>/dev/null || true)"
fi
# Combine all options
local opts="$commands $flags $cliproxy_profiles $profiles"
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
return 0
local tokens_before_current=()
if [[ ${COMP_CWORD} -gt 1 ]]; then
tokens_before_current=("${COMP_WORDS[@]:1:COMP_CWORD-1}")
fi
# CLIProxy provider flags (gemini, codex, agy, qwen)
if [[ ${COMP_WORDS[1]} =~ ^(gemini|codex|agy|qwen)$ ]]; then
local provider_flags="--auth --config --logout --headless --help -h"
COMPREPLY=( $(compgen -W "${provider_flags}" -- ${cur}) )
return 0
fi
# auth subcommands
if [[ ${prev} == "auth" ]]; then
local auth_commands="create list show remove default --help -h"
COMPREPLY=( $(compgen -W "${auth_commands}" -- ${cur}) )
return 0
fi
# api subcommands
if [[ ${prev} == "api" ]]; then
local api_commands="create list remove --help -h"
COMPREPLY=( $(compgen -W "${api_commands}" -- ${cur}) )
return 0
fi
# cliproxy subcommands
if [[ ${prev} == "cliproxy" ]]; then
local cliproxy_commands="create list remove --install --latest --help -h"
COMPREPLY=( $(compgen -W "${cliproxy_commands}" -- ${cur}) )
return 0
fi
# Completion for cliproxy subcommands
if [[ ${COMP_WORDS[1]} == "cliproxy" ]]; then
case "${prev}" in
remove|delete|rm)
# Complete with cliproxy variant names
if [[ -f ~/.ccs/config.json ]]; then
local variants=$(jq -r '.cliproxy | keys[]' ~/.ccs/config.json 2>/dev/null || true)
COMPREPLY=( $(compgen -W "${variants}" -- ${cur}) )
fi
return 0
;;
create)
# Complete with create flags
COMPREPLY=( $(compgen -W "--provider --model --force --yes -y" -- ${cur}) )
return 0
;;
--provider)
# Complete with provider names
COMPREPLY=( $(compgen -W "gemini codex agy qwen" -- ${cur}) )
return 0
;;
list|ls)
# No flags for list
return 0
;;
--install)
# User enters version number
return 0
;;
esac
fi
# Completion for api subcommands
if [[ ${COMP_WORDS[1]} == "api" ]]; then
case "${prev}" in
remove|delete|rm)
# Complete with settings profile names
if [[ -f ~/.ccs/config.json ]]; then
local profiles=$(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null || true)
COMPREPLY=( $(compgen -W "${profiles}" -- ${cur}) )
fi
return 0
;;
create)
# Complete with create flags
COMPREPLY=( $(compgen -W "--base-url --api-key --model --force --yes -y" -- ${cur}) )
return 0
;;
list)
# No flags for list
return 0
;;
esac
fi
# Completion for auth subcommands that need profile names
if [[ ${COMP_WORDS[1]} == "auth" ]]; then
case "${prev}" in
show|remove|default)
# Complete with account profile names only
if [[ -f ~/.ccs/profiles.json ]]; then
local profiles=$(jq -r '.profiles | keys[]' ~/.ccs/profiles.json 2>/dev/null || true)
COMPREPLY=( $(compgen -W "${profiles}" -- ${cur}) )
fi
return 0
;;
create)
# Complete with create flags
COMPREPLY=( $(compgen -W "--force" -- ${cur}) )
return 0
;;
list)
# Complete with list flags
COMPREPLY=( $(compgen -W "--verbose --json" -- ${cur}) )
return 0
;;
esac
fi
# env subcommands
if [[ ${COMP_WORDS[1]} == "env" ]]; then
case "${prev}" in
env)
# Complete with profile names and flags (inline profiles since $cliproxy_profiles is out of scope)
local env_opts="--format --shell --ide --help -h gemini codex agy qwen iflow kiro ghcp claude default"
if [[ -f ~/.ccs/config.json ]]; then
env_opts="$env_opts $(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null || true)"
fi
if [[ -f ~/.ccs/profiles.json ]]; then
env_opts="$env_opts $(jq -r '.profiles | keys[]' ~/.ccs/profiles.json 2>/dev/null || true)"
fi
COMPREPLY=( $(compgen -W "${env_opts}" -- ${cur}) )
return 0
;;
--format)
COMPREPLY=( $(compgen -W "openai anthropic raw claude-extension" -- ${cur}) )
return 0
;;
--shell)
COMPREPLY=( $(compgen -W "auto bash zsh fish powershell" -- ${cur}) )
return 0
;;
--ide)
COMPREPLY=( $(compgen -W "vscode cursor windsurf" -- ${cur}) )
return 0
;;
*)
COMPREPLY=( $(compgen -W "--format --shell --ide --help -h" -- ${cur}) )
return 0
;;
esac
fi
# Flags for doctor command
if [[ ${COMP_WORDS[1]} == "doctor" ]]; then
COMPREPLY=( $(compgen -W "--help -h" -- ${cur}) )
return 0
fi
# Flags for update command
if [[ ${COMP_WORDS[1]} == "update" ]]; then
COMPREPLY=( $(compgen -W "--force --beta --dev --help -h" -- ${cur}) )
return 0
fi
# Flags for shell-completion command
if [[ ${prev} == "--shell-completion" || ${prev} == "-sc" ]]; then
COMPREPLY=( $(compgen -W "--bash --zsh --fish --powershell" -- ${cur}) )
return 0
fi
while IFS= read -r line; do
[[ -n "${line}" ]] && COMPREPLY+=("${line}")
done < <(__ccs_completion_run "${cur}" "${tokens_before_current[@]}")
return 0
}
# Register completion function
__ccs_completion_run() {
local current="$1"
shift || true
local script_dir repo_root repo_cli
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "${script_dir}/../.." && pwd)"
repo_cli="${repo_root}/dist/ccs.js"
if [[ ! -f "${repo_cli}" ]]; then
repo_cli="${repo_root}/bin/ccs.js"
fi
if [[ -f "${repo_cli}" ]]; then
node "${repo_cli}" __complete --shell bash --current "${current}" -- "$@" 2>/dev/null
return 0
fi
if command -v ccs >/dev/null 2>&1; then
ccs __complete --shell bash --current "${current}" -- "$@" 2>/dev/null
fi
}
complete -F _ccs_completion ccs
+24 -250
View File
@@ -1,256 +1,30 @@
# Fish completion for CCS (Claude Code Switch)
# Compatible with fish 3.0+
#
# Features:
# - Categorized completions with [cmd], [proxy], [model], and [account] prefixes
# - Dynamic profile loading from config.json and profiles.json
# - Context-aware subcommand completion
#
# Installation:
# Copy to ~/.config/fish/completions/:
# mkdir -p ~/.config/fish/completions
# cp scripts/completion/ccs.fish ~/.config/fish/completions/
#
# Fish will automatically load completions from this directory.
# No need to source or reload - completions are loaded on demand.
# Helper function to get settings profiles
function __fish_ccs_get_settings_profiles
set -l config_path ~/.ccs/config.json
function __fish_ccs_complete
set -l tokens_before_current (commandline -opc)
if test (count $tokens_before_current) -gt 0
set -e tokens_before_current[1]
end
if test -f $config_path
jq -r '.profiles | keys[]' $config_path 2>/dev/null
set -l current (commandline -ct)
if test -n "$current"; and test (count $tokens_before_current) -gt 0; and test "$tokens_before_current[-1]" = "$current"
set -e tokens_before_current[-1]
end
set -l script_file (status filename)
set -l repo_root (realpath (dirname $script_file)/../.. 2>/dev/null)
set -l repo_cli "$repo_root/dist/ccs.js"
if not test -f "$repo_cli"
set repo_cli "$repo_root/bin/ccs.js"
end
if test -f "$repo_cli"
node "$repo_cli" __complete --shell fish --current "$current" -- $tokens_before_current 2>/dev/null
return
end
if command -sq ccs
ccs __complete --shell fish --current "$current" -- $tokens_before_current 2>/dev/null
end
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 kimi
if test -f $config_path
set -l all_profiles (jq -r '.profiles | keys[]' $config_path 2>/dev/null)
for profile in $all_profiles
if not contains $profile $known_profiles
echo $profile
end
end
end
end
# Helper function to get cliproxy variants
function __fish_ccs_get_cliproxy_variants
set -l config_path ~/.ccs/config.json
if test -f $config_path
jq -r '.cliproxy | keys[]' $config_path 2>/dev/null
end
end
# Helper function to get profiles with all types
function __fish_ccs_get_profiles
__fish_ccs_get_settings_profiles
__fish_ccs_get_account_profiles
end
# Helper function to get account profiles only
function __fish_ccs_get_account_profiles
set -l profiles_path ~/.ccs/profiles.json
if test -f $profiles_path
jq -r '.profiles | keys[]' $profiles_path 2>/dev/null
end
end
# Helper function to check if we're in auth context
function __fish_ccs_using_auth
__fish_seen_subcommand_from auth
end
# Helper function to check specific auth subcommand
function __fish_ccs_using_auth_subcommand
set -l subcommand $argv[1]
__fish_ccs_using_auth; and __fish_seen_subcommand_from $subcommand
end
# Helper function to check if we're in api context
function __fish_ccs_using_api
__fish_seen_subcommand_from api
end
# Helper function to check specific api subcommand
function __fish_ccs_using_api_subcommand
set -l subcommand $argv[1]
__fish_ccs_using_api; and __fish_seen_subcommand_from $subcommand
end
# Helper function to check if we're in cliproxy context
function __fish_ccs_using_cliproxy
__fish_seen_subcommand_from cliproxy
end
# Helper function to check specific cliproxy subcommand
function __fish_ccs_using_cliproxy_subcommand
set -l subcommand $argv[1]
__fish_ccs_using_cliproxy; and __fish_seen_subcommand_from $subcommand
end
# Helper function to check if we're in profile context
function __fish_ccs_using_profile
__fish_seen_subcommand_from profile
end
# Helper function to check specific profile subcommand
function __fish_ccs_using_profile_subcommand
set -l subcommand $argv[1]
__fish_ccs_using_profile; and __fish_seen_subcommand_from $subcommand
end
# Helper function to check if we're using a CLIProxy provider
function __fish_ccs_using_provider
__fish_seen_subcommand_from gemini codex agy qwen
end
# Disable file completion for ccs
complete -c ccs -f
# Top-level flags
complete -c ccs -s h -l help -d 'Show help message'
complete -c ccs -s v -l version -d 'Show version information'
complete -c ccs -s sc -l shell-completion -d 'Install shell completion'
# Commands - grouped with [cmd] 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 'auth' -d '[cmd] Manage multiple Claude accounts'
complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'api' -d '[cmd] Manage API profiles (create/remove)'
complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'cliproxy' -d '[cmd] Manage CLIProxy variants and binary'
complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'doctor' -d '[cmd] Run health check and diagnostics'
complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'env' -d '[cmd] Export env vars for third-party tools'
complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'sync' -d '[cmd] Sync delegation commands and skills'
complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'update' -d '[cmd] Update CCS to latest version'
# CLIProxy profiles - grouped with [proxy] prefix for OAuth providers
complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'gemini' -d '[proxy] Google Gemini (OAuth)'
complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'codex' -d '[proxy] OpenAI Codex (OAuth)'
complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'agy' -d '[proxy] Antigravity (OAuth)'
complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a 'qwen' -d '[proxy] Qwen Code (OAuth)'
# 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 'kimi' -d '[model] Kimi for Coding (long-context)'
# Custom model profiles - dynamic with [model] prefix
complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a '(__fish_ccs_get_custom_settings_profiles)' -d '[model] Settings-based profile'
# CLIProxy variants - dynamic with [variant] prefix
complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a '(__fish_ccs_get_cliproxy_variants)' -d '[variant] CLIProxy variant'
# Account profiles - dynamic with [account] prefix
complete -c ccs -n 'not __fish_seen_subcommand_from auth api cliproxy doctor env sync update gemini codex agy qwen' -a '(__fish_ccs_get_account_profiles)' -d '[account] Account-based profile'
# shell-completion subflags
complete -c ccs -n '__fish_seen_argument -l shell-completion; or __fish_seen_argument -s sc' -l bash -d 'Install for bash'
complete -c ccs -n '__fish_seen_argument -l shell-completion; or __fish_seen_argument -s sc' -l zsh -d 'Install for zsh'
complete -c ccs -n '__fish_seen_argument -l shell-completion; or __fish_seen_argument -s sc' -l fish -d 'Install for fish'
complete -c ccs -n '__fish_seen_argument -l shell-completion; or __fish_seen_argument -s sc' -l powershell -d 'Install for PowerShell'
# CLIProxy provider flags (gemini, codex, agy, qwen)
complete -c ccs -n '__fish_ccs_using_provider' -l auth -d 'Authenticate only'
complete -c ccs -n '__fish_ccs_using_provider' -l config -d 'Change model configuration'
complete -c ccs -n '__fish_ccs_using_provider' -l logout -d 'Clear authentication'
complete -c ccs -n '__fish_ccs_using_provider' -l headless -d 'Headless auth (for SSH)'
complete -c ccs -n '__fish_ccs_using_provider' -s h -l help -d 'Show help'
# update command flags
complete -c ccs -n '__fish_seen_subcommand_from update' -l force -d 'Force reinstall current version'
complete -c ccs -n '__fish_seen_subcommand_from update' -l beta -d 'Install from dev channel'
complete -c ccs -n '__fish_seen_subcommand_from update' -l dev -d 'Install from dev channel'
complete -c ccs -n '__fish_seen_subcommand_from update' -s h -l help -d 'Show help'
# doctor command flags
complete -c ccs -n '__fish_seen_subcommand_from doctor' -s h -l help -d 'Show help for doctor command'
# env command completions
complete -c ccs -n '__fish_seen_subcommand_from env; and not __fish_seen_argument -l format -l shell' -a 'gemini codex agy qwen iflow kiro ghcp claude' -d '[proxy] CLIProxy profile'
complete -c ccs -n '__fish_seen_subcommand_from env' -l format -d 'Output format'
complete -c ccs -n '__fish_seen_subcommand_from env; and __fish_seen_argument -l format' -a 'openai anthropic raw' -d 'Format'
complete -c ccs -n '__fish_seen_subcommand_from env' -l shell -d 'Shell syntax'
complete -c ccs -n '__fish_seen_subcommand_from env; and __fish_seen_argument -l shell' -a 'auto bash zsh fish powershell' -d 'Shell'
complete -c ccs -n '__fish_seen_subcommand_from env' -s h -l help -d 'Show help for env command'
# ============================================================================
# auth subcommands
# ============================================================================
complete -c ccs -n '__fish_ccs_using_auth; and not __fish_seen_subcommand_from create list show remove default' -a 'create' -d 'Create new profile and login'
complete -c ccs -n '__fish_ccs_using_auth; and not __fish_seen_subcommand_from create list show remove default' -a 'list' -d 'List all saved profiles'
complete -c ccs -n '__fish_ccs_using_auth; and not __fish_seen_subcommand_from create list show remove default' -a 'show' -d 'Show profile details'
complete -c ccs -n '__fish_ccs_using_auth; and not __fish_seen_subcommand_from create list show remove default' -a 'remove' -d 'Remove saved profile'
complete -c ccs -n '__fish_ccs_using_auth; and not __fish_seen_subcommand_from create list show remove default' -a 'default' -d 'Set default profile'
complete -c ccs -n '__fish_ccs_using_auth' -s h -l help -d 'Show help for auth commands'
complete -c ccs -n '__fish_ccs_using_auth_subcommand create' -l force -d 'Allow overwriting existing profile'
complete -c ccs -n '__fish_ccs_using_auth_subcommand list' -l verbose -d 'Show additional details'
complete -c ccs -n '__fish_ccs_using_auth_subcommand list' -l json -d 'Output in JSON format'
complete -c ccs -n '__fish_ccs_using_auth_subcommand show' -a '(__fish_ccs_get_account_profiles)' -d 'Account profile'
complete -c ccs -n '__fish_ccs_using_auth_subcommand show' -l json -d 'Output in JSON format'
complete -c ccs -n '__fish_ccs_using_auth_subcommand remove' -a '(__fish_ccs_get_account_profiles)' -d 'Account profile'
complete -c ccs -n '__fish_ccs_using_auth_subcommand remove' -l yes -d 'Skip confirmation prompts'
complete -c ccs -n '__fish_ccs_using_auth_subcommand remove' -s y -d 'Skip confirmation prompts'
complete -c ccs -n '__fish_ccs_using_auth_subcommand default' -a '(__fish_ccs_get_account_profiles)' -d 'Account profile'
# ============================================================================
# api subcommands
# ============================================================================
complete -c ccs -n '__fish_ccs_using_api; and not __fish_seen_subcommand_from create list remove' -a 'create' -d 'Create new API profile'
complete -c ccs -n '__fish_ccs_using_api; and not __fish_seen_subcommand_from create list remove' -a 'list' -d 'List all API profiles'
complete -c ccs -n '__fish_ccs_using_api; and not __fish_seen_subcommand_from create list remove' -a 'remove' -d 'Remove an API profile'
complete -c ccs -n '__fish_ccs_using_api' -s h -l help -d 'Show help for api commands'
complete -c ccs -n '__fish_ccs_using_api_subcommand create' -l base-url -d 'API base URL'
complete -c ccs -n '__fish_ccs_using_api_subcommand create' -l api-key -d 'API key'
complete -c ccs -n '__fish_ccs_using_api_subcommand create' -l model -d 'Default model'
complete -c ccs -n '__fish_ccs_using_api_subcommand create' -l force -d 'Overwrite existing profile'
complete -c ccs -n '__fish_ccs_using_api_subcommand create' -l yes -d 'Skip prompts'
complete -c ccs -n '__fish_ccs_using_api_subcommand create' -s y -d 'Skip prompts'
complete -c ccs -n '__fish_ccs_using_api_subcommand remove' -a '(__fish_ccs_get_settings_profiles)' -d 'Settings profile'
complete -c ccs -n '__fish_ccs_using_api_subcommand remove' -l yes -d 'Skip confirmation'
complete -c ccs -n '__fish_ccs_using_api_subcommand remove' -s y -d 'Skip confirmation'
# ============================================================================
# cliproxy subcommands
# ============================================================================
complete -c ccs -n '__fish_ccs_using_cliproxy; and not __fish_seen_subcommand_from create list remove' -a 'create' -d 'Create new CLIProxy variant'
complete -c ccs -n '__fish_ccs_using_cliproxy; and not __fish_seen_subcommand_from create list remove' -a 'list' -d 'List all CLIProxy variants'
complete -c ccs -n '__fish_ccs_using_cliproxy; and not __fish_seen_subcommand_from create list remove' -a 'remove' -d 'Remove a CLIProxy variant'
complete -c ccs -n '__fish_ccs_using_cliproxy' -s h -l help -d 'Show help for cliproxy commands'
complete -c ccs -n '__fish_ccs_using_cliproxy' -l install -d 'Install specific version'
complete -c ccs -n '__fish_ccs_using_cliproxy' -l latest -d 'Install latest version'
complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand create' -l provider -d 'Provider name'
complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand create; and __fish_seen_argument -l provider' -a 'gemini codex agy qwen' -d 'Provider'
complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand create' -l model -d 'Model name'
complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand create' -l force -d 'Overwrite existing variant'
complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand create' -l yes -d 'Skip prompts'
complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand create' -s y -d 'Skip prompts'
complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand remove' -a '(__fish_ccs_get_cliproxy_variants)' -d 'CLIProxy variant'
complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand remove' -l yes -d 'Skip confirmation'
complete -c ccs -n '__fish_ccs_using_cliproxy_subcommand remove' -s y -d 'Skip confirmation'
# ============================================================================
# profile subcommands (legacy - redirects to api)
# ============================================================================
complete -c ccs -n '__fish_ccs_using_profile; and not __fish_seen_subcommand_from create list remove' -a 'create' -d 'Create new API profile'
complete -c ccs -n '__fish_ccs_using_profile; and not __fish_seen_subcommand_from create list remove' -a 'list' -d 'List all profiles'
complete -c ccs -n '__fish_ccs_using_profile; and not __fish_seen_subcommand_from create list remove' -a 'remove' -d 'Remove a profile'
complete -c ccs -n '__fish_ccs_using_profile' -s h -l help -d 'Show help for profile commands'
complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -l base-url -d 'API base URL'
complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -l api-key -d 'API key'
complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -l model -d 'Default model'
complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -l force -d 'Overwrite existing profile'
complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -l yes -d 'Skip prompts'
complete -c ccs -n '__fish_ccs_using_profile_subcommand create' -s y -d 'Skip prompts'
complete -c ccs -n '__fish_ccs_using_profile_subcommand remove' -a '(__fish_ccs_get_settings_profiles)' -d 'Settings profile'
complete -c ccs -n '__fish_ccs_using_profile_subcommand remove' -l yes -d 'Skip confirmation'
complete -c ccs -n '__fish_ccs_using_profile_subcommand remove' -s y -d 'Skip confirmation'
complete -c ccs -f -a "(__fish_ccs_complete)"
+42 -432
View File
@@ -1,446 +1,56 @@
# PowerShell completion for CCS (Claude Code Switch)
# Compatible with PowerShell 5.1+
#
# Installation:
# Add to your PowerShell profile ($PROFILE):
# . /path/to/ccs/scripts/completion/ccs.ps1
#
# Or install for current user:
# Copy-Item scripts/completion/ccs.ps1 ~\Documents\PowerShell\Scripts\
# Add to profile: . ~\Documents\PowerShell\Scripts\ccs.ps1
function Invoke-CcsCompletionBackend {
param(
[string]$CurrentWord,
[string[]]$TokensBeforeCurrent
)
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$repoCli = Join-Path $repoRoot 'dist\ccs.js'
if (-not (Test-Path $repoCli)) {
$repoCli = Join-Path $repoRoot 'bin\ccs.js'
}
if (Test-Path $repoCli) {
& node $repoCli __complete --shell powershell --current $CurrentWord -- @TokensBeforeCurrent 2>$null
return
}
if (Get-Command ccs -ErrorAction SilentlyContinue) {
& ccs __complete --shell powershell --current $CurrentWord -- @TokensBeforeCurrent 2>$null
}
}
Register-ArgumentCompleter -CommandName ccs -ScriptBlock {
param($commandName, $wordToComplete, $commandAst, $fakeBoundParameters)
$commands = @('auth', 'api', 'cliproxy', 'doctor', 'env', 'sync', 'update', '--help', '--version', '--shell-completion', '-h', '-v', '-sc')
$cliproxyProfiles = @('gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp', 'claude')
$authCommands = @('create', 'list', 'show', 'remove', 'default', '--help', '-h')
$apiCommands = @('create', 'list', 'remove', '--help', '-h')
$cliproxyCommands = @('create', 'list', 'remove', '--install', '--latest', '--help', '-h')
$apiCreateFlags = @('--base-url', '--api-key', '--model', '--force', '--yes', '-y')
$cliproxyCreateFlags = @('--provider', '--model', '--force', '--yes', '-y')
$providerFlags = @('--auth', '--config', '--logout', '--headless', '--help', '-h')
$updateFlags = @('--force', '--beta', '--dev', '--help', '-h')
$envFlags = @('--format', '--shell', '--help', '-h')
$envFormats = @('openai', 'anthropic', 'raw')
$envShells = @('auto', 'bash', 'zsh', 'fish', 'powershell')
$shellCompletionFlags = @('--bash', '--zsh', '--fish', '--powershell')
$listFlags = @('--verbose', '--json')
$removeFlags = @('--yes', '-y')
$showFlags = @('--json')
$providers = @('gemini', 'codex', 'agy', 'qwen')
# Get current position in command
$words = $commandAst.ToString() -split '\s+' | Where-Object { $_ -ne '' }
$position = $words.Count
# Helper function to get profiles
function Get-CcsProfiles {
param([string]$Type = 'all')
$profiles = @()
# Settings-based profiles
if ($Type -in @('all', 'settings')) {
$configPath = "$env:USERPROFILE\.ccs\config.json"
if (Test-Path $configPath) {
try {
$config = Get-Content $configPath -Raw | ConvertFrom-Json
$profiles += $config.profiles.PSObject.Properties.Name
} catch {}
$commandElements = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text })
$tokensBeforeCurrent = @()
if ($commandElements.Count -gt 1) {
$tokensBeforeCurrent = $commandElements[1..($commandElements.Count - 1)]
if ($tokensBeforeCurrent.Count -gt 0 -and $tokensBeforeCurrent[-1] -eq $wordToComplete) {
$tokensBeforeCurrent = if ($tokensBeforeCurrent.Count -gt 1) {
$tokensBeforeCurrent[0..($tokensBeforeCurrent.Count - 2)]
} else {
@()
}
}
# Account-based profiles
if ($Type -in @('all', 'account')) {
$profilesPath = "$env:USERPROFILE\.ccs\profiles.json"
if (Test-Path $profilesPath) {
try {
$data = Get-Content $profilesPath -Raw | ConvertFrom-Json
$profiles += $data.profiles.PSObject.Properties.Name
} catch {}
}
}
# CLIProxy variants
if ($Type -in @('all', 'cliproxy')) {
$configPath = "$env:USERPROFILE\.ccs\config.json"
if (Test-Path $configPath) {
try {
$config = Get-Content $configPath -Raw | ConvertFrom-Json
if ($config.cliproxy) {
$profiles += $config.cliproxy.PSObject.Properties.Name
}
} catch {}
}
}
return $profiles | Sort-Object -Unique
}
# Top-level completion
if ($position -eq 2) {
$allOptions = $commands + $cliproxyProfiles + (Get-CcsProfiles)
$allOptions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
foreach ($line in Invoke-CcsCompletionBackend -CurrentWord $wordToComplete -TokensBeforeCurrent $tokensBeforeCurrent) {
if ([string]::IsNullOrWhiteSpace($line)) {
continue
}
return
}
# shell-completion flag completion
if ($words[1] -eq '--shell-completion' -or $words[1] -eq '-sc') {
if ($position -eq 3) {
$shellCompletionFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
return
}
$parts = $line -split "`t", 2
$value = $parts[0]
$description = if ($parts.Count -gt 1) { $parts[1] } else { $parts[0] }
# CLIProxy provider flags (gemini, codex, agy, qwen)
if ($words[1] -in $cliproxyProfiles) {
$providerFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
return
}
# update command completion
if ($words[1] -eq 'update') {
$updateFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
return
}
# env command completion
if ($words[1] -eq 'env') {
if ($position -eq 3) {
$options = $cliproxyProfiles + (Get-CcsProfiles -Type settings) + $envFlags
$options | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
} elseif ($position -ge 4) {
switch ($words[$position - 2]) {
'--format' {
$envFormats | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
'--shell' {
$envShells | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
default {
$envFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
}
}
return
}
# auth subcommand completion
if ($words[1] -eq 'auth') {
if ($position -eq 3) {
$authCommands | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
} elseif ($position -eq 4) {
switch ($words[2]) {
'show' {
$options = (Get-CcsProfiles -Type account) + $showFlags
$options | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
'remove' {
$options = (Get-CcsProfiles -Type account) + $removeFlags
$options | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
'default' {
Get-CcsProfiles -Type account | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
'list' {
$listFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
'create' {
@('--force') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
}
} elseif ($position -eq 5) {
switch ($words[2]) {
'show' {
$showFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
'remove' {
$removeFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
}
}
return
}
# api subcommand completion
if ($words[1] -eq 'api') {
if ($position -eq 3) {
$apiCommands | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
} elseif ($position -eq 4) {
switch ($words[2]) {
'remove' {
$options = (Get-CcsProfiles -Type settings) + $removeFlags
$options | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
'create' {
$apiCreateFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
}
} elseif ($position -eq 5) {
switch ($words[2]) {
'remove' {
$removeFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
}
}
return
}
# cliproxy subcommand completion
if ($words[1] -eq 'cliproxy') {
if ($position -eq 3) {
$cliproxyCommands | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
} elseif ($position -eq 4) {
switch ($words[2]) {
'remove' {
$options = (Get-CcsProfiles -Type cliproxy) + $removeFlags
$options | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
'create' {
$cliproxyCreateFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
}
} elseif ($position -eq 5) {
switch ($words[2]) {
'remove' {
$removeFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
'create' {
# After --provider, complete with provider names
if ($words[3] -eq '--provider') {
$providers | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
}
}
}
return
}
# profile subcommand completion (legacy)
if ($words[1] -eq 'profile') {
if ($position -eq 3) {
@('create', 'list', 'remove', '--help', '-h') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
} elseif ($position -eq 4) {
switch ($words[2]) {
'remove' {
$options = (Get-CcsProfiles -Type settings) + $removeFlags
$options | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
'create' {
$apiCreateFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
}
} elseif ($position -eq 5) {
switch ($words[2]) {
'remove' {
$removeFlags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new(
$_,
$_,
'ParameterValue',
$_
)
}
}
}
}
return
[System.Management.Automation.CompletionResult]::new(
$value,
$value,
'ParameterValue',
$description
)
}
}
+26 -304
View File
@@ -1,319 +1,41 @@
#compdef ccs
# Zsh completion for CCS (Claude Code Switch)
# Compatible with zsh 5.0+
#
# Installation:
# Add to ~/.zshrc:
# fpath=(~/.zsh/completion $fpath)
# autoload -Uz compinit && compinit
# source /path/to/ccs/scripts/completion/ccs.zsh
#
# Or install system-wide:
# sudo cp scripts/completion/ccs.zsh /usr/local/share/zsh/site-functions/_ccs
# 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|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'
zstyle ':completion:*:*:ccs:*' list-separator ' -- '
zstyle ':completion:*:*:ccs:*' list-rows-first true
zstyle ':completion:*:*:ccs:*' menu select
_ccs() {
local -a commands proxy_profiles settings_profiles_described account_profiles_described cliproxy_variants_described
local curcontext="$curcontext" state line
typeset -A opt_args
local current
current="${words[CURRENT]}"
# Define top-level commands
commands=(
'auth:Manage multiple Claude accounts'
'api:Manage API profiles (create/remove)'
'cliproxy:Manage CLIProxy variants and binary'
'doctor:Run health check and diagnostics'
'env:Export env vars for third-party tools'
'sync:Sync delegation commands and skills'
'update:Update CCS to latest version'
)
# Define CLIProxy hardcoded profiles (OAuth providers)
proxy_profiles=(
'gemini:Google Gemini (OAuth)'
'codex:OpenAI Codex (OAuth)'
'agy:Antigravity (OAuth)'
'qwen:Qwen Code (OAuth)'
'iflow:iFlow (OAuth)'
'kiro:Kiro (OAuth)'
'ghcp:GitHub Copilot (OAuth)'
'claude:Claude Direct (OAuth)'
)
# Define known settings profiles with descriptions
local -A profile_descriptions
profile_descriptions=(
'default' 'Default Claude Sonnet 4.5'
'glm' 'GLM-4.6 (cost-optimized)'
'kimi' 'Kimi for Coding (long-context)'
)
# Load settings-based profiles from config.json
if [[ -f ~/.ccs/config.json ]]; then
local -a raw_settings_profiles
raw_settings_profiles=(${(f)"$(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null)"})
for profile in $raw_settings_profiles; do
local desc="${profile_descriptions[$profile]:-Settings-based profile}"
settings_profiles_described+=("${profile}:${desc}")
done
local -a tokens_before_current
if (( CURRENT > 2 )); then
tokens_before_current=("${words[@]:2:$((CURRENT-2))}")
else
tokens_before_current=()
fi
# Load account-based profiles from profiles.json
if [[ -f ~/.ccs/profiles.json ]]; then
local -a raw_account_profiles
raw_account_profiles=(${(f)"$(jq -r '.profiles | keys[]' ~/.ccs/profiles.json 2>/dev/null)"})
for profile in $raw_account_profiles; do
account_profiles_described+=("${profile}:Account-based profile")
done
fi
# Load cliproxy variants from config.json
if [[ -f ~/.ccs/config.json ]]; then
local -a raw_cliproxy_variants
raw_cliproxy_variants=(${(f)"$(jq -r '.cliproxy | keys[]' ~/.ccs/config.json 2>/dev/null)"})
for variant in $raw_cliproxy_variants; do
cliproxy_variants_described+=("${variant}:CLIProxy variant")
done
fi
_arguments -C \
'(- *)'{-h,--help}'[Show help message]' \
'(- *)'{-v,--version}'[Show version information]' \
'(- *)'{-sc,--shell-completion}'[Install shell completion]' \
'1: :->command' \
'*:: :->args'
case $state in
command)
_describe -t commands 'commands' commands
_describe -t proxy-profiles 'CLIProxy profiles' proxy_profiles
_describe -t model-profiles 'model profiles' settings_profiles_described
_describe -t account-profiles 'account profiles' account_profiles_described
_describe -t cliproxy-variants 'CLIProxy variants' cliproxy_variants_described
;;
args)
case $words[1] in
auth)
_ccs_auth
;;
api)
_ccs_api
;;
cliproxy)
_ccs_cliproxy
;;
update)
_arguments \
'--force[Force reinstall current version]' \
'--beta[Install from dev channel]' \
'--dev[Install from dev channel]' \
'(- *)'{-h,--help}'[Show help]'
;;
doctor)
_arguments \
'(- *)'{-h,--help}'[Show help for doctor command]'
;;
env)
_arguments \
'--format[Output format]:format:(openai anthropic raw)' \
'--shell[Shell syntax]:shell:(auto bash zsh fish powershell)' \
'(- *)'{-h,--help}'[Show help]' \
'1:profile:($proxy_profiles ${(k)settings_profiles_described})'
;;
gemini|codex|agy|qwen)
_arguments \
'--auth[Authenticate only]' \
'--config[Change model configuration]' \
'--logout[Clear authentication]' \
'--headless[Headless auth (for SSH)]' \
'(- *)'{-h,--help}'[Show help]'
;;
--shell-completion|-sc)
_arguments \
'--bash[Install for bash]' \
'--zsh[Install for zsh]' \
'--fish[Install for fish]' \
'--powershell[Install for PowerShell]'
;;
*)
_message 'Claude CLI arguments'
;;
esac
;;
esac
local -a suggestions
suggestions=("${(@f)$(__ccs_completion_run "${current}" "${tokens_before_current[@]}")}")
compadd -- "${suggestions[@]}"
}
_ccs_api() {
local curcontext="$curcontext" state line
typeset -A opt_args
__ccs_completion_run() {
local current="$1"
shift || true
local -a api_commands settings_profiles
api_commands=(
'create:Create new API profile (interactive)'
'list:List all API profiles'
'remove:Remove an API profile'
)
if [[ -f ~/.ccs/config.json ]]; then
settings_profiles=(${(f)"$(jq -r '.profiles | keys[]' ~/.ccs/config.json 2>/dev/null)"})
local script_path script_dir repo_root repo_cli
script_path="${(%):-%N}"
script_dir="${script_path:A:h}"
repo_root="${script_dir:h:h}"
repo_cli="${repo_root}/dist/ccs.js"
if [[ ! -f "${repo_cli}" ]]; then
repo_cli="${repo_root}/bin/ccs.js"
fi
if [[ -f "${repo_cli}" ]]; then
node "${repo_cli}" __complete --shell zsh --current "${current}" -- "$@" 2>/dev/null
return 0
fi
_arguments -C \
'(- *)'{-h,--help}'[Show help for api commands]' \
'1: :->subcommand' \
'*:: :->subargs'
case $state in
subcommand)
_describe -t api-commands 'api commands' api_commands
;;
subargs)
case $words[1] in
create)
_arguments \
'1:profile name:' \
'--base-url[API base URL]:url:' \
'--api-key[API key]:key:' \
'--model[Default model]:model:' \
'--force[Overwrite existing profile]' \
{--yes,-y}'[Skip prompts]'
;;
list)
;;
remove|delete|rm)
_arguments \
'1:profile:($settings_profiles)' \
{--yes,-y}'[Skip confirmation]'
;;
esac
;;
esac
}
_ccs_cliproxy() {
local curcontext="$curcontext" state line
typeset -A opt_args
local -a cliproxy_commands cliproxy_variants providers
cliproxy_commands=(
'create:Create new CLIProxy variant profile'
'list:List all CLIProxy variant profiles'
'remove:Remove a CLIProxy variant profile'
)
providers=(gemini codex agy qwen)
if [[ -f ~/.ccs/config.json ]]; then
cliproxy_variants=(${(f)"$(jq -r '.cliproxy | keys[]' ~/.ccs/config.json 2>/dev/null)"})
if (( $+commands[ccs] )); then
ccs __complete --shell zsh --current "${current}" -- "$@" 2>/dev/null
fi
_arguments -C \
'(- *)'{-h,--help}'[Show help for cliproxy commands]' \
'--install[Install specific version]:version:' \
'--latest[Install latest version]' \
'1: :->subcommand' \
'*:: :->subargs'
case $state in
subcommand)
_describe -t cliproxy-commands 'cliproxy commands' cliproxy_commands
;;
subargs)
case $words[1] in
create)
_arguments \
'1:variant name:' \
'--provider[Provider name]:provider:($providers)' \
'--model[Model name]:model:' \
'--force[Overwrite existing variant]' \
{--yes,-y}'[Skip prompts]'
;;
list|ls)
;;
remove|delete|rm)
_arguments \
'1:variant:($cliproxy_variants)' \
{--yes,-y}'[Skip confirmation]'
;;
esac
;;
esac
}
_ccs_auth() {
local curcontext="$curcontext" state line
typeset -A opt_args
local -a auth_commands account_profiles
auth_commands=(
'create:Create new profile and login'
'list:List all saved profiles'
'show:Show profile details'
'remove:Remove saved profile'
'default:Set default profile'
)
if [[ -f ~/.ccs/profiles.json ]]; then
account_profiles=(${(f)"$(jq -r '.profiles | keys[]' ~/.ccs/profiles.json 2>/dev/null)"})
fi
_arguments -C \
'(- *)'{-h,--help}'[Show help for auth commands]' \
'1: :->subcommand' \
'*:: :->subargs'
case $state in
subcommand)
_describe -t auth-commands 'auth commands' auth_commands
;;
subargs)
case $words[1] in
create)
_message 'new profile name'
_arguments '--force[Allow overwriting existing profile]'
;;
list)
_arguments \
'--verbose[Show additional details]' \
'--json[Output in JSON format]'
;;
show)
_arguments \
'1:profile:($account_profiles)' \
'--json[Output in JSON format]'
;;
remove)
_arguments \
'1:profile:($account_profiles)' \
{--yes,-y}'[Skip confirmation prompts]'
;;
default)
_arguments '1:profile:($account_profiles)'
;;
esac
;;
esac
}
_ccs "$@"
@@ -11,6 +11,7 @@ import type { TargetType } from '../../targets/target-adapter';
import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata';
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
import { isSensitiveKey } from '../../utils/sensitive-keys';
import { isReservedName } from '../../config/reserved-names';
import { isUnifiedMode, mutateUnifiedConfig } from '../../config/unified-config-loader';
@@ -219,6 +220,7 @@ export function registerApiProfileOrphans(options?: {
try {
if (orphan.validation.valid) {
ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
}
registerApiProfileInConfig(orphan.name, options?.target || 'claude', options?.force || false);
result.registered.push(orphan.name);
@@ -269,6 +271,7 @@ export function copyApiProfile(
writeJsonObjectAtomically(destinationSettingsPath, sourceSettings);
try {
ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
} catch (hookError) {
rollbackSettingsFile(destinationSettingsPath, previousDestinationContent, destinationExisted);
throw hookError;
@@ -394,6 +397,7 @@ export function importApiProfileBundle(
writeJsonObjectAtomically(settingsPath, settings);
try {
ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
} catch (hookError) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw hookError;
+3
View File
@@ -9,6 +9,7 @@ import { expandPath } from '../../utils/helpers';
import { validateApiName } from './validation-service';
import { mutateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
import type { TargetType } from '../../targets/target-adapter';
import { resolveDroidProvider } from '../../targets/droid-provider';
import { isReservedName } from '../../config/reserved-names';
@@ -127,6 +128,7 @@ function createSettingsFile(
try {
ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
@@ -216,6 +218,7 @@ function createApiProfileUnified(
try {
ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
+18
View File
@@ -21,6 +21,7 @@ import packageJson from '../../package.json';
import {
type CommandContext,
handleCreate,
handleBackup,
handleList,
handleShow,
handleRemove,
@@ -68,6 +69,9 @@ class AuthCommands {
console.log('');
console.log(subheader('Commands'));
console.log(` ${color('create <profile>', 'command')} Create new profile and login`);
console.log(
` ${color('backup <profile>', 'command')} Backup local continuity for an account`
);
console.log(` ${color('list', 'command')} List all saved profiles`);
console.log(` ${color('show <profile>', 'command')} Show profile details`);
console.log(` ${color('remove <profile>', 'command')} Remove saved profile`);
@@ -97,6 +101,12 @@ class AuthCommands {
console.log(` ${dim('# Set work as default')}`);
console.log(` ${color('ccs auth default work', 'command')}`);
console.log('');
console.log(` ${dim('# Backup the local continuity lane for an account or plain ccs')}`);
console.log(` ${color('ccs auth backup work', 'command')}`);
console.log(
` ${color('ccs auth backup default', 'command')} ${dim('# backup plain ccs lane')}`
);
console.log('');
console.log(` ${dim('# Restore original CCS behavior')}`);
console.log(` ${color('ccs auth reset-default', 'command')}`);
console.log('');
@@ -162,6 +172,10 @@ class AuthCommands {
return handleCreate(this.getContext(), args);
}
async handleBackup(args: string[]): Promise<void> {
return handleBackup(this.getContext(), args);
}
/**
* List all profiles - delegates to list-command.ts
*/
@@ -227,6 +241,10 @@ class AuthCommands {
await this.handleList(commandArgs);
break;
case 'backup':
await this.handleBackup(commandArgs);
break;
case 'show':
await this.handleShow(commandArgs);
break;
+139
View File
@@ -0,0 +1,139 @@
import * as fs from 'fs';
import * as path from 'path';
import { initUI, color, dim, fail, info, ok } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import {
createTimestampStamp,
getAuthBackupRoot,
getContinuityArtifactNames,
resolveRuntimePlainCcsResumeLane,
} from '../resume-lane-diagnostics';
import { isAccountContextMetadata, resolveAccountContextPolicy } from '../account-context';
import { CommandContext, parseArgs } from './types';
interface BackupManifest {
target: string;
source_config_dir: string;
created_at: string;
copied: string[];
skipped: string[];
}
function copyDirectoryIfPresent(sourcePath: string, targetPath: string): boolean {
if (!fs.existsSync(sourcePath)) {
return false;
}
fs.cpSync(sourcePath, targetPath, {
recursive: true,
dereference: true,
force: false,
errorOnExist: false,
});
return true;
}
export async function handleBackup(ctx: CommandContext, args: string[]): Promise<void> {
await initUI();
const { profileName, json } = parseArgs(args);
if (!profileName) {
console.log(fail('Profile name is required'));
console.log('');
console.log(`Usage: ${color('ccs auth backup <profile|default> [--json]', 'command')}`);
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
}
try {
let sourceConfigDir = '';
let backupLabel = profileName;
let artifactNames: string[] = [];
if (profileName === 'default') {
const lane = await resolveRuntimePlainCcsResumeLane();
sourceConfigDir = lane.configDir;
backupLabel = lane.accountName ? `default-${lane.accountName}` : 'default';
artifactNames = getContinuityArtifactNames('default');
} else {
const profiles = ctx.registry.getAllProfilesMerged();
const profile = profiles[profileName];
if (!profile || profile.type !== 'account') {
exitWithError(
`Backup supports auth accounts or "default". Not found: ${profileName}`,
ExitCode.PROFILE_ERROR
);
}
const contextPolicy = resolveAccountContextPolicy(
isAccountContextMetadata(profile) ? profile : undefined
);
sourceConfigDir = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, {
bare: profile.bare === true,
});
artifactNames = getContinuityArtifactNames('account');
}
const backupRoot = path.join(getAuthBackupRoot(), backupLabel, createTimestampStamp());
fs.mkdirSync(backupRoot, { recursive: true, mode: 0o700 });
const copied: string[] = [];
const skipped: string[] = [];
for (const artifactName of artifactNames) {
const sourcePath = path.join(sourceConfigDir, artifactName);
const targetPath = path.join(backupRoot, artifactName);
if (copyDirectoryIfPresent(sourcePath, targetPath)) {
copied.push(artifactName);
} else {
skipped.push(artifactName);
}
}
const manifest: BackupManifest = {
target: profileName,
source_config_dir: sourceConfigDir,
created_at: new Date().toISOString(),
copied,
skipped,
};
fs.writeFileSync(
path.join(backupRoot, 'manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`,
{
encoding: 'utf8',
mode: 0o600,
}
);
if (json) {
console.log(
JSON.stringify(
{
target: profileName,
backup_path: backupRoot,
copied,
skipped,
},
null,
2
)
);
return;
}
console.log(ok(`Continuity backup created: ${backupRoot}`));
console.log('');
console.log(info(`Source lane: ${sourceConfigDir}`));
console.log(` ${dim(`Copied: ${copied.length > 0 ? copied.join(', ') : 'none'}`)}`);
if (skipped.length > 0) {
console.log(` ${dim(`Skipped: ${skipped.join(', ')}`)}`);
}
console.log('');
} catch (error) {
exitWithError(
`Failed to create continuity backup: ${(error as Error).message}`,
ExitCode.GENERAL_ERROR
);
}
}
+1
View File
@@ -16,6 +16,7 @@ export {
// Command handlers
export { handleCreate } from './create-command';
export { handleBackup } from './backup-command';
export { handleList } from './list-command';
export { handleShow } from './show-command';
export { handleRemove } from './remove-command';
+17 -10
View File
@@ -100,6 +100,19 @@ function resolveMappedAccount(
return undefined;
}
export function resolveConfiguredContinuitySourceAccount(
profileName: string,
profileType: ProfileType
): string | undefined {
const inheritFromAccount = getContinuityInheritanceMap();
return (
resolveMappedAccount(profileName, profileType, inheritFromAccount) ??
(!isUnifiedMode()
? resolveMappedAccount(profileName, profileType, loadLegacyContinuityInheritanceMap())
: undefined)
);
}
/**
* Resolve optional cross-profile continuity inheritance.
*
@@ -115,16 +128,10 @@ export async function resolveProfileContinuityInheritance(
return {};
}
const inheritFromAccount = getContinuityInheritanceMap();
const sourceAccount =
resolveMappedAccount(input.profileName, input.profileType, inheritFromAccount) ??
(!isUnifiedMode()
? resolveMappedAccount(
input.profileName,
input.profileType,
loadLegacyContinuityInheritanceMap()
)
: undefined);
const sourceAccount = resolveConfiguredContinuitySourceAccount(
input.profileName,
input.profileType
);
if (!sourceAccount) {
return {};
}
+46 -1
View File
@@ -16,11 +16,12 @@ import { Config, Settings, ProfileMetadata } from '../types';
import {
UnifiedConfig,
CopilotConfig,
CursorConfig,
CLIProxyVariantConfig,
CompositeVariantConfig,
CompositeTierConfig,
} from '../config/unified-config-types';
import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader';
import { loadUnifiedConfig, isUnifiedMode, getCursorConfig } from '../config/unified-config-loader';
import { getCcsDir } from '../utils/config-manager';
import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat';
import type { CLIProxyProvider } from '../cliproxy/types';
@@ -49,6 +50,8 @@ export interface ProfileDetectionResult {
env?: Record<string, string>;
/** For copilot profile: the copilot config */
copilotConfig?: CopilotConfig;
/** For cursor profile: the cursor config */
cursorConfig?: CursorConfig;
/** For composite variants: true when variant mixes providers per tier */
isComposite?: boolean;
/** For composite variants: which tier is the default */
@@ -252,6 +255,7 @@ class ProfileDetector {
* Priority order:
* 0. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen)
* 0.5. Copilot profile (if enabled in config)
* 0.75. Cursor profile (if enabled in config)
* 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1)
* 2. User-defined CLIProxy variants (config.cliproxy section) [legacy]
* 3. Settings-based profiles (config.profiles section) [legacy]
@@ -302,6 +306,34 @@ class ProfileDetector {
};
}
// Priority 0.75: Check Cursor profile - local Cursor daemon runtime
if (profileName === 'cursor') {
const cursorConfig = getCursorConfig();
if (!cursorConfig?.enabled) {
const error = new Error(
'Cursor profile is not enabled.\n\n' +
'To enable Cursor integration:\n' +
' 1. Run: ccs cursor enable\n' +
' 2. Import auth: ccs cursor auth\n' +
' 3. Start daemon: ccs cursor start\n\n' +
'Or manually edit ~/.ccs/config.yaml:\n' +
' cursor:\n' +
' enabled: true'
) as ProfileNotFoundError;
error.profileName = profileName;
error.suggestions = [];
error.availableProfiles = this.listAvailableProfiles();
throw error;
}
return {
type: 'cursor',
name: 'cursor',
cursorConfig,
};
}
// Priority 1: Try unified config if available
const unifiedConfig = this.readUnifiedConfig();
if (unifiedConfig) {
@@ -430,6 +462,14 @@ class ProfileDetector {
};
}
/**
* Public wrapper for diagnostics and tooling that need to inspect how
* plain `ccs` resolves without duplicating the default-profile logic.
*/
resolveDefaultProfileResult(): ProfileDetectionResult {
return this.resolveDefaultProfile();
}
/**
* List available profiles (for error messages)
*/
@@ -452,6 +492,11 @@ class ProfileDetector {
lines.push(` - copilot (model: ${currentCopilotModel})`);
}
if (unifiedConfig.cursor?.enabled) {
lines.push('Cursor local proxy:');
lines.push(` - cursor (model: ${unifiedConfig.cursor.model})`);
}
// CLIProxy variants from unified config
const variants = Object.keys(unifiedConfig.cliproxy?.variants || {});
if (variants.length > 0) {
+163
View File
@@ -0,0 +1,163 @@
import * as fs from 'fs';
import * as path from 'path';
import { getDefaultClaudeConfigDir } from '../utils/claude-config-path';
import { getCcsDir } from '../utils/config-manager';
import InstanceManager from '../management/instance-manager';
import ProfileDetector from './profile-detector';
import { resolveConfiguredContinuitySourceAccount } from './profile-continuity-inheritance';
import type { ProfileType } from '../types/profile';
export type ResumeLaneKind =
| 'native'
| 'account-default'
| 'account-inherited'
| 'profile-default'
| 'ambient';
export interface ResumeLaneSummary {
kind: ResumeLaneKind;
label: string;
configDir: string;
accountName?: string;
profileName?: string;
projectCount: number;
}
export interface ResumeFlagIntent {
implicit: boolean;
explicitSessionId?: string;
}
function countTopLevelProjectDirs(projectsDir: string): number {
try {
return fs
.readdirSync(projectsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory()).length;
} catch {
return 0;
}
}
function resolveNativeLaneSummary(
kind: ResumeLaneKind = 'native',
profileName?: string
): ResumeLaneSummary {
const configDir = getDefaultClaudeConfigDir();
const label =
kind === 'profile-default' && profileName
? `profile "${profileName}" via native Claude lane`
: 'native Claude lane';
return {
kind,
label,
configDir,
profileName,
projectCount: countTopLevelProjectDirs(path.join(configDir, 'projects')),
};
}
function resolveAccountLaneSummary(
kind: Extract<ResumeLaneKind, 'account-default' | 'account-inherited'>,
accountName: string
): ResumeLaneSummary {
const instanceMgr = new InstanceManager();
const configDir = instanceMgr.getInstancePath(accountName);
return {
kind,
label:
kind === 'account-inherited'
? `plain ccs inherits from account "${accountName}"`
: `plain ccs defaults to account "${accountName}"`,
configDir,
accountName,
projectCount: countTopLevelProjectDirs(path.join(configDir, 'projects')),
};
}
export async function resolveConfiguredPlainCcsResumeLane(): Promise<ResumeLaneSummary> {
const detector = new ProfileDetector();
const defaultProfile = detector.resolveDefaultProfileResult();
if (defaultProfile.type === 'account') {
return resolveAccountLaneSummary('account-default', defaultProfile.name);
}
const inheritedAccount = resolveConfiguredContinuitySourceAccount(
defaultProfile.name,
defaultProfile.type
);
if (inheritedAccount) {
return resolveAccountLaneSummary('account-inherited', inheritedAccount);
}
if (defaultProfile.type !== 'default') {
return resolveNativeLaneSummary('profile-default', defaultProfile.name);
}
return resolveNativeLaneSummary();
}
export async function resolveRuntimePlainCcsResumeLane(): Promise<ResumeLaneSummary> {
if (process.env.CLAUDE_CONFIG_DIR) {
const configDir = path.resolve(process.env.CLAUDE_CONFIG_DIR);
return {
kind: 'ambient',
label: 'current shell CLAUDE_CONFIG_DIR',
configDir,
projectCount: countTopLevelProjectDirs(path.join(configDir, 'projects')),
};
}
return resolveConfiguredPlainCcsResumeLane();
}
export function parseResumeFlagIntent(args: string[]): ResumeFlagIntent | null {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '-r') {
return { implicit: true };
}
if (arg === '--resume') {
const next = args[index + 1];
if (next && !next.startsWith('-')) {
return { implicit: false, explicitSessionId: next };
}
return { implicit: true };
}
if (arg.startsWith('--resume=')) {
const sessionId = arg.slice('--resume='.length).trim();
return sessionId ? { implicit: false, explicitSessionId: sessionId } : { implicit: true };
}
}
return null;
}
export function getAuthBackupRoot(): string {
return path.join(getCcsDir(), 'backups', 'auth-continuity');
}
export function createTimestampStamp(date = new Date()): string {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${yyyy}${mm}${dd}_${hh}${min}${ss}`;
}
export function getContinuityArtifactNames(profileType: ProfileType): string[] {
const sharedItems = ['projects'];
const deeperItems = ['session-env', 'file-history', 'shell-snapshots', 'todos'];
if (profileType === 'default' || profileType === 'account') {
return [...sharedItems, ...deeperItems];
}
return sharedItems;
}
+64
View File
@@ -0,0 +1,64 @@
import * as path from 'path';
import { info, warn } from '../utils/ui';
import {
parseResumeFlagIntent,
resolveRuntimePlainCcsResumeLane,
type ResumeLaneSummary,
} from './resume-lane-diagnostics';
interface ResumeLaneWarningDependencies {
resolvePlainLane?: () => Promise<ResumeLaneSummary>;
log?: (message: string) => void;
debug?: boolean;
}
export async function maybeWarnAboutResumeLaneMismatch(
profileName: string,
accountConfigDir: string,
args: string[],
deps: ResumeLaneWarningDependencies = {}
): Promise<void> {
const resumeIntent = parseResumeFlagIntent(args);
if (!resumeIntent) {
return;
}
const log = deps.log ?? console.error;
try {
const plainLane = await (deps.resolvePlainLane ?? resolveRuntimePlainCcsResumeLane)();
if (path.resolve(plainLane.configDir) === path.resolve(accountConfigDir)) {
return;
}
log(
warn(
`Resume for account "${profileName}" will search that account lane, not the current plain ccs lane.`
)
);
log(info(` Account lane: ${accountConfigDir}`));
log(info(` Plain ccs lane: ${plainLane.label} (${plainLane.configDir})`));
if (resumeIntent.explicitSessionId) {
log(
info(
' This explicit session ID may have been created in a different lane, so Claude may not find it here.'
)
);
}
log(info(' Recover the original lane first: ccs -r'));
log(info(' Back it up before changing setup: ccs auth backup default'));
log(
info(` For future work, align plain ccs with this account: ccs auth default ${profileName}`)
);
log('');
} catch (error) {
log(
warn(
'Resume lane guidance skipped because diagnostics failed; continuing with the account lane.'
)
);
if (deps.debug ?? Boolean(process.env.CCS_DEBUG)) {
log(info(` Diagnostic error: ${(error as Error).message}`));
}
}
}
+103 -7
View File
@@ -31,11 +31,19 @@ import {
appendThirdPartyWebSearchToolArgs,
createWebSearchTraceContext,
} from './utils/websearch-manager';
import {
ensureImageAnalysisMcpOrThrow,
syncImageAnalysisMcpToConfigDir,
appendThirdPartyImageAnalysisToolArgs,
} from './utils/image-analysis';
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector';
import {
applyImageAnalysisRuntimeOverrides,
getImageAnalysisHookEnv,
installImageAnalyzerHook,
prepareImageAnalysisFallbackHook,
resolveImageAnalysisRuntimeConnection,
resolveImageAnalysisRuntimeStatus,
} from './utils/hooks';
import { fail, info, warn } from './utils/ui';
@@ -47,6 +55,7 @@ import {
resolveOfficialChannelsLaunchPlan,
} from './channels/official-channels-runtime';
import { getOfficialChannelReadiness } from './channels/official-channels-store';
import { isCursorSubcommandToken } from './cursor/constants';
// Import centralized error handling
import { handleError, runCleanup } from './errors';
@@ -55,6 +64,7 @@ import { tryHandleRootCommand } from './commands/root-command-router';
// Import extracted utility functions
import { execClaude } from './utils/shell-executor';
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
// Import target adapter system
import {
@@ -310,10 +320,11 @@ async function main(): Promise<void> {
registerTarget(new CodexAdapter());
const args = process.argv.slice(2);
const isCompletionCommand = args[0] === '__complete';
// Initialize UI colors early to ensure consistent colored output
// Must happen before any status messages (ok, info, fail, etc.)
if (process.stdout.isTTY && !process.env['CI']) {
if (!isCompletionCommand && process.stdout.isTTY && !process.env['CI']) {
const { initUI } = await import('./utils/ui');
await initUI();
}
@@ -353,7 +364,7 @@ async function main(): Promise<void> {
// Security warning: cloud sync paths expose OAuth tokens
const cloudService = detectCloudSyncPath(configDirValue);
if (cloudService) {
if (!isCompletionCommand && cloudService) {
console.error(warn(`CCS directory is under ${cloudService}.`));
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
@@ -364,7 +375,7 @@ async function main(): Promise<void> {
} else if (process.env.CCS_DIR) {
// Also warn for CCS_DIR env var pointing to cloud sync
const cloudService = detectCloudSyncPath(process.env.CCS_DIR);
if (cloudService) {
if (!isCompletionCommand && cloudService) {
console.error(warn(`CCS directory is under ${cloudService}.`));
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
@@ -372,13 +383,18 @@ async function main(): Promise<void> {
} else if (process.env.CCS_HOME) {
// Also warn for CCS_HOME env var pointing to cloud sync
const cloudService = detectCloudSyncPath(process.env.CCS_HOME);
if (cloudService) {
if (!isCompletionCommand && cloudService) {
console.error(warn(`CCS directory is under ${cloudService}.`));
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
}
}
if (isCompletionCommand) {
await tryHandleRootCommand(args);
return;
}
if (shouldPassthroughNativeCodexFlagCommand(args)) {
execNativeCodexFlagCommand(args);
return;
@@ -450,6 +466,18 @@ async function main(): Promise<void> {
}
}
// Special case: cursor command (Cursor local proxy integration)
// Route known admin subcommands to the command handler, keep all other args as profile passthrough.
if (firstArg === 'cursor' && args.length > 1) {
const { handleCursorCommand } = await import('./commands/cursor-command');
const cursorToken = args[1];
if (isCursorSubcommandToken(cursorToken)) {
const exitCode = await handleCursorCommand(args.slice(1));
process.exit(exitCode);
}
}
// First-time install: offer setup wizard for interactive users
// Check independently of recovery status (user may have empty config.yaml)
// Skip if headless, CI, or non-TTY environment
@@ -685,7 +713,10 @@ async function main(): Promise<void> {
// CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants
if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
}
const imageAnalysisFallbackHookReady =
resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false;
const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider);
// Inject Image Analyzer hook into profile settings before launch
ensureImageAnalyzerHooks({
@@ -694,6 +725,7 @@ async function main(): Promise<void> {
cliproxyProvider: provider,
isComposite: profileInfo.isComposite,
settingsPath: profileInfo.settingsPath ? expandPath(profileInfo.settingsPath) : undefined,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
const variantPort = profileInfo.port; // variant-specific port for isolation
@@ -848,11 +880,14 @@ async function main(): Promise<void> {
} else if (profileInfo.type === 'copilot') {
// COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy
ensureWebSearchMcpOrThrow();
installImageAnalyzerHook();
ensureImageAnalysisMcpOrThrow();
const imageAnalysisFallbackHookReady =
resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false;
// Inject Image Analyzer hook into profile settings before launch
ensureImageAnalyzerHooks({
profileName: profileInfo.name,
profileType: profileInfo.type,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
const { executeCopilotProfile } = await import('./copilot');
@@ -880,11 +915,46 @@ async function main(): Promise<void> {
claudeCli
);
process.exit(exitCode);
} else if (profileInfo.type === 'cursor') {
// CURSOR FLOW: local Cursor daemon profile
ensureWebSearchMcpOrThrow();
installImageAnalyzerHook();
ensureImageAnalyzerHooks({
profileName: profileInfo.name,
profileType: profileInfo.type,
});
const { executeCursorProfile } = await import('./cursor');
const cursorConfig = profileInfo.cursorConfig;
if (!cursorConfig) {
console.error(fail('Cursor configuration not found'));
process.exit(1);
}
const continuityInheritance = await resolveProfileContinuityInheritance({
profileName: profileInfo.name,
profileType: profileInfo.type,
target: resolvedTarget,
});
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
console.error(
info(
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"`
)
);
}
const exitCode = await executeCursorProfile(
cursorConfig,
remainingArgs,
continuityInheritance.claudeConfigDir,
claudeCli
);
process.exit(exitCode);
} else if (profileInfo.type === 'settings') {
// Settings-based profiles (glm, glmt) are third-party providers
const imageAnalysisMcpReady =
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow();
installImageAnalyzerHook();
}
// Display WebSearch status (single line, equilibrium UX)
@@ -907,6 +977,9 @@ async function main(): Promise<void> {
}
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
const imageAnalysisFallbackHookReady =
resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false;
const expandedSettingsPath =
resolvedSettingsPath ??
(profileInfo.settingsPath
@@ -920,6 +993,7 @@ async function main(): Promise<void> {
settingsPath: expandedSettingsPath,
settings,
cliproxyBridge,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
if (resolvedTarget !== 'claude') {
const compatibility = evaluateTargetRuntimeCompatibility({
@@ -1022,13 +1096,31 @@ async function main(): Promise<void> {
profileType: profileInfo.type,
settings,
cliproxyBridge,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
const runtimeConnection = resolveImageAnalysisRuntimeConnection();
let imageAnalysisEnv = getImageAnalysisHookEnv({
profileName: profileInfo.name,
profileType: profileInfo.type,
settings,
cliproxyBridge,
});
imageAnalysisEnv = applyImageAnalysisRuntimeOverrides(imageAnalysisEnv, {
backendId: imageAnalysisStatus.backendId,
model: imageAnalysisStatus.model,
runtimePath: imageAnalysisStatus.runtimePath,
baseUrl: runtimeConnection.baseUrl,
apiKey: runtimeConnection.apiKey,
allowSelfSigned: runtimeConnection.allowSelfSigned,
});
if (!imageAnalysisMcpReady) {
imageAnalysisEnv = {
...imageAnalysisEnv,
CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
}
const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER'];
if (
@@ -1127,10 +1219,13 @@ async function main(): Promise<void> {
return;
}
const imageAnalysisArgs = imageAnalysisMcpReady
? appendThirdPartyImageAnalysisToolArgs(remainingArgs)
: remainingArgs;
const launchArgs = [
'--settings',
expandedSettingsPath,
...appendThirdPartyWebSearchToolArgs(remainingArgs),
...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs),
];
const traceEnv = createWebSearchTraceContext({
launcher: 'ccs.settings-profile',
@@ -1175,6 +1270,7 @@ async function main(): Promise<void> {
CCS_WEBSEARCH_SKIP: '1',
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
await maybeWarnAboutResumeLaneMismatch(profileInfo.name, instancePath, remainingArgs);
const launchArgs = resolveNativeClaudeLaunchArgs(remainingArgs, 'account', instancePath);
execClaude(claudeCli, launchArgs, envVars);
} else {
+27 -2
View File
@@ -20,7 +20,11 @@ import { applyExtendedContextConfig } from '../config/extended-context-config';
import { CLIProxyProvider } from '../types';
import { CompositeTierConfig } from '../../config/unified-config-types';
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
import { getImageAnalysisHookEnv } from '../../utils/hooks/get-image-analysis-hook-env';
import {
applyImageAnalysisRuntimeOverrides,
getImageAnalysisHookEnv,
resolveImageAnalysisRuntimeConnection,
} from '../../utils/hooks/get-image-analysis-hook-env';
import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks/image-analysis-runtime-status';
import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector';
import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer';
@@ -30,6 +34,7 @@ import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
import { MODEL_ENV_VAR_KEYS, normalizeModelIdForProvider } from '../model-id-normalizer';
import type { ProxyTarget } from '../proxy-target-resolver';
import { getEffectiveApiKey } from '../auth-token-manager';
import { isSettings, type Settings } from '../../types/config';
export interface RemoteProxyConfig {
@@ -76,6 +81,7 @@ interface CliproxyImageAnalysisDeps {
hasImageAnalysisProfileHook: typeof hasImageAnalysisProfileHook;
hasImageAnalyzerHook: typeof hasImageAnalyzerHook;
resolveImageAnalysisRuntimeStatus: typeof resolveImageAnalysisRuntimeStatus;
getLocalRuntimeApiKey: typeof getEffectiveApiKey;
}
interface ResolveCliproxyImageAnalysisEnvOptions {
@@ -84,6 +90,7 @@ interface ResolveCliproxyImageAnalysisEnvOptions {
profileSettingsPath?: string;
isComposite?: boolean;
proxyTarget: ProxyTarget;
tunnelPort?: number | null;
proxyReachable: boolean;
}
@@ -97,6 +104,7 @@ const defaultCliproxyImageAnalysisDeps: CliproxyImageAnalysisDeps = {
hasImageAnalysisProfileHook,
hasImageAnalyzerHook,
resolveImageAnalysisRuntimeStatus,
getLocalRuntimeApiKey: getEffectiveApiKey,
};
const CODEX_EFFORT_SUFFIX_REGEX = /^(.*)-(xhigh|high|medium)$/i;
@@ -190,7 +198,24 @@ export async function resolveCliproxyImageAnalysisEnv(
};
}
return { env, warning: null };
const runtimeConnection = resolveImageAnalysisRuntimeConnection({
proxyTarget: options.proxyTarget,
tunnelPort: options.tunnelPort,
});
return {
env: applyImageAnalysisRuntimeOverrides(env, {
backendId: status.backendId,
model: status.model,
runtimePath: status.runtimePath,
baseUrl: runtimeConnection.baseUrl,
apiKey: options.proxyTarget.isRemote
? runtimeConnection.apiKey
: resolvedDeps.getLocalRuntimeApiKey(),
allowSelfSigned: runtimeConnection.allowSelfSigned,
}),
warning: null,
};
}
/**
+37 -14
View File
@@ -58,8 +58,12 @@ import {
appendThirdPartyWebSearchToolArgs,
createWebSearchTraceContext,
} from '../../utils/websearch-manager';
import {
ensureImageAnalysisMcpOrThrow,
syncImageAnalysisMcpToConfigDir,
appendThirdPartyImageAnalysisToolArgs,
} from '../../utils/image-analysis';
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
import { installImageAnalyzerHook } from '../../utils/hooks';
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
import { isKiroAuthMethod, KiroAuthMethod, normalizeKiroAuthMethod } from '../auth/auth-types';
import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance';
@@ -205,11 +209,9 @@ export async function execClaudeWithCLIProxy(
// Setup first-class CCS WebSearch runtime
ensureWebSearchMcpOrThrow();
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
displayWebSearchStatus();
// Sync image analyzer hook from npm package to ~/.ccs/hooks/
installImageAnalyzerHook();
const providerConfig = getProviderConfig(provider);
log(`Provider: ${providerConfig.displayName}`);
warnOAuthBanRisk(provider);
@@ -841,15 +843,27 @@ export async function execClaudeWithCLIProxy(
protocol: 'http' as const,
isRemote: false as const,
};
const { env: imageAnalysisEnv, warning: imageAnalysisWarning } =
await resolveCliproxyImageAnalysisEnv({
profileName: cfg.profileName || provider,
provider,
profileSettingsPath: cfg.customSettingsPath,
isComposite: cfg.isComposite,
proxyTarget: imageAnalysisProxyTarget,
proxyReachable: true,
});
const imageAnalysisResolution = await resolveCliproxyImageAnalysisEnv({
profileName: cfg.profileName || provider,
provider,
profileSettingsPath: cfg.customSettingsPath,
isComposite: cfg.isComposite,
proxyTarget: imageAnalysisProxyTarget,
tunnelPort,
proxyReachable: true,
});
const imageAnalysisProvisioningFailed =
!imageAnalysisMcpReady && imageAnalysisResolution.env.CCS_IMAGE_ANALYSIS_ENABLED === '1';
const imageAnalysisEnv = imageAnalysisProvisioningFailed
? {
...imageAnalysisResolution.env,
CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1',
}
: imageAnalysisResolution.env;
const imageAnalysisWarning = imageAnalysisProvisioningFailed
? 'ImageAnalysis MCP provisioning failed. This session will use native Read.'
: imageAnalysisResolution.warning;
// 9. Setup tool sanitization proxy
let toolSanitizationProxy: ToolSanitizationProxy | null = null;
@@ -870,6 +884,8 @@ export async function execClaudeWithCLIProxy(
}
}
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
// Build initial env vars to get ANTHROPIC_BASE_URL
const initialEnvVars = buildClaudeEnvironment({
provider,
@@ -1072,7 +1088,14 @@ export async function execClaudeWithCLIProxy(
: getProviderSettingsPath(provider);
let claude: ChildProcess;
const launchArgs = ['--settings', settingsPath, ...appendThirdPartyWebSearchToolArgs(claudeArgs)];
const imageAnalysisArgs = imageAnalysisMcpReady
? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
: claudeArgs;
const launchArgs = [
'--settings',
settingsPath,
...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs),
];
const traceEnv = createWebSearchTraceContext({
launcher: 'cliproxy.executor',
args: launchArgs,
+169
View File
@@ -0,0 +1,169 @@
import type { GeminiCliBucket } from './quota-types';
export interface GeminiCliParsedBucket {
modelId: string;
tokenType: string | null;
remainingFraction: number | null;
remainingAmount: number | null;
resetTime: string | null;
}
interface GeminiCliQuotaGroupDefinition {
id: string;
label: string;
preferredModelId?: string;
modelIds: string[];
}
const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
{
id: 'gemini-flash-lite-series',
label: 'Gemini Flash Lite Series',
preferredModelId: 'gemini-2.5-flash-lite',
modelIds: ['gemini-2.5-flash-lite'],
},
{
id: 'gemini-flash-series',
label: 'Gemini Flash Series',
preferredModelId: 'gemini-3-flash-preview',
modelIds: ['gemini-3-flash-preview', 'gemini-3.1-flash-preview', 'gemini-2.5-flash'],
},
{
id: 'gemini-pro-series',
label: 'Gemini Pro Series',
preferredModelId: 'gemini-3.1-pro-preview',
modelIds: ['gemini-3.1-pro-preview', 'gemini-3-pro-preview', 'gemini-2.5-pro'],
},
];
const GEMINI_CLI_GROUP_ORDER = new Map(
GEMINI_CLI_QUOTA_GROUPS.map((group, index) => [group.id, index] as const)
);
const GEMINI_CLI_GROUP_LOOKUP = new Map(
GEMINI_CLI_QUOTA_GROUPS.flatMap((group) =>
group.modelIds.map((modelId) => [modelId, group] as const)
)
);
const GEMINI_CLI_IGNORED_MODEL_PREFIXES = ['gemini-2.0-flash'];
type GeminiCliQuotaBucketGroup = {
id: string;
label: string;
tokenType: string | null;
modelIds: string[];
preferredModelId?: string;
preferredBucket?: GeminiCliParsedBucket;
fallbackRemainingFraction: number | null;
fallbackRemainingAmount: number | null;
fallbackResetTime: string | null;
};
function isIgnoredGeminiCliModel(modelId: string): boolean {
return GEMINI_CLI_IGNORED_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix));
}
function pickEarlierResetTime(current: string | null, next: string | null): string | null {
if (!current) return next;
if (!next) return current;
const currentTime = new Date(current).getTime();
const nextTime = new Date(next).getTime();
if (Number.isNaN(currentTime)) return next;
if (Number.isNaN(nextTime)) return current;
return currentTime <= nextTime ? current : next;
}
function minNullableNumber(current: number | null, next: number | null): number | null {
if (current === null) return next;
if (next === null) return current;
return Math.min(current, next);
}
function clampQuotaFraction(value: number | null): number {
if (value === null) return 0;
return Math.max(0, Math.min(1, value));
}
function getGroupOrder(bucket: GeminiCliQuotaBucketGroup): number {
return GEMINI_CLI_GROUP_ORDER.get(bucket.id) ?? Number.MAX_SAFE_INTEGER;
}
export function buildGeminiCliBucketsFromParsedBuckets(
buckets: GeminiCliParsedBucket[]
): GeminiCliBucket[] {
if (buckets.length === 0) return [];
const grouped = new Map<string, GeminiCliQuotaBucketGroup>();
for (const bucket of buckets) {
if (isIgnoredGeminiCliModel(bucket.modelId)) continue;
const group = GEMINI_CLI_GROUP_LOOKUP.get(bucket.modelId);
const groupId = group?.id ?? bucket.modelId;
const label = group?.label ?? bucket.modelId;
const tokenKey = bucket.tokenType ?? '';
const cacheKey = `${groupId}::${tokenKey || 'combined'}`;
const existing = grouped.get(cacheKey);
if (!existing) {
const preferredModelId = group?.preferredModelId;
grouped.set(cacheKey, {
id: groupId,
label,
tokenType: bucket.tokenType,
modelIds: [bucket.modelId],
preferredModelId,
preferredBucket:
preferredModelId && bucket.modelId === preferredModelId ? bucket : undefined,
fallbackRemainingFraction: bucket.remainingFraction,
fallbackRemainingAmount: bucket.remainingAmount,
fallbackResetTime: bucket.resetTime,
});
continue;
}
existing.modelIds.push(bucket.modelId);
existing.fallbackRemainingFraction = minNullableNumber(
existing.fallbackRemainingFraction,
bucket.remainingFraction
);
existing.fallbackRemainingAmount = minNullableNumber(
existing.fallbackRemainingAmount,
bucket.remainingAmount
);
existing.fallbackResetTime = pickEarlierResetTime(existing.fallbackResetTime, bucket.resetTime);
if (existing.preferredModelId && bucket.modelId === existing.preferredModelId) {
existing.preferredBucket = bucket;
}
}
return Array.from(grouped.values())
.sort((a, b) => {
const orderDiff = getGroupOrder(a) - getGroupOrder(b);
if (orderDiff !== 0) return orderDiff;
const tokenDiff = (a.tokenType ?? '').localeCompare(b.tokenType ?? '');
if (tokenDiff !== 0) return tokenDiff;
return a.label.localeCompare(b.label);
})
.map((bucket) => {
const preferred = bucket.preferredBucket;
const remainingFraction = clampQuotaFraction(
preferred?.remainingFraction ?? bucket.fallbackRemainingFraction
);
const remainingAmount = preferred?.remainingAmount ?? bucket.fallbackRemainingAmount;
const resetTime = preferred?.resetTime ?? bucket.fallbackResetTime;
return {
id: `${bucket.id}::${bucket.tokenType || 'combined'}`,
label: bucket.label,
tokenType: bucket.tokenType,
remainingFraction,
remainingPercent: Math.round(remainingFraction * 100),
remainingAmount,
resetTime,
modelIds: Array.from(new Set(bucket.modelIds)),
};
});
}
+179 -111
View File
@@ -11,43 +11,28 @@ import { getAuthDir } from './config-generator';
import { getProviderAccounts, getPausedDir } from './account-manager';
import { sanitizeEmail, isTokenExpired } from './auth-utils';
import { refreshGeminiToken } from './auth/gemini-token-refresh';
import {
buildGeminiCliBucketsFromParsedBuckets,
type GeminiCliParsedBucket,
} from './gemini-cli-quota-normalizer';
import type { GeminiCliQuotaResult, GeminiCliBucket } from './quota-types';
/** Google Cloud Code API endpoints */
const GEMINI_CLI_API_BASE = 'https://cloudcode-pa.googleapis.com';
const GEMINI_CLI_API_VERSION = 'v1internal';
const GEMINI_CLI_QUOTA_URL = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERSION}:retrieveUserQuota`;
const GEMINI_CLI_CODE_ASSIST_URL = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERSION}:loadCodeAssist`;
const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320;
const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]';
/**
* Model groups for quota consolidation.
* Update when Google releases new Gemini models to include them in quota display.
*/
const GEMINI_CLI_GROUPS: Record<
string,
{
label: string;
models: string[];
}
> = {
'gemini-flash-series': {
label: 'Gemini Flash Series',
models: [
'gemini-3-flash-preview',
'gemini-3.1-flash-preview',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
],
},
'gemini-pro-series': {
label: 'Gemini Pro Series',
models: ['gemini-3-pro-preview', 'gemini-3.1-pro-preview', 'gemini-2.5-pro'],
},
const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI';
const GEMINI_CLI_TIER_LABELS: Record<string, string> = {
'free-tier': 'Free',
'legacy-tier': 'Legacy',
'standard-tier': 'Standard',
'g1-pro-tier': 'Pro',
'g1-ultra-tier': 'Ultra',
};
/** Models to ignore in quota display (deprecated) */
const IGNORED_MODEL_PREFIXES = ['gemini-2.0-flash'];
/** Auth data extracted from Gemini CLI auth file */
interface GeminiCliAuthData {
accessToken: string;
@@ -75,12 +60,38 @@ interface GeminiCliQuotaResponse {
buckets?: RawGeminiCliBucket[];
}
interface GeminiCliCredits {
creditType?: string;
credit_type?: string;
creditAmount?: string | number;
credit_amount?: string | number;
}
interface GeminiCliUserTier {
id?: string;
availableCredits?: GeminiCliCredits[];
available_credits?: GeminiCliCredits[];
}
interface GeminiCliCodeAssistResponse {
currentTier?: GeminiCliUserTier | null;
current_tier?: GeminiCliUserTier | null;
paidTier?: GeminiCliUserTier | null;
paid_tier?: GeminiCliUserTier | null;
}
interface ParsedGeminiCliErrorBody {
errorCode?: string;
errorDetail?: string;
message?: string;
}
interface GeminiCliSupplementaryInfo {
tierLabel: string | null;
tierId: string | null;
creditBalance: number | null;
}
/**
* Extract project ID from account field
* Input: "user@example.com (cloudaicompanion-abc-123)"
@@ -234,23 +245,112 @@ function readGeminiCliAuthData(accountId: string): GeminiCliAuthData | null {
return null;
}
/**
* Find which group a model belongs to
*/
function findModelGroup(modelId: string): { groupId: string; label: string } | null {
for (const [groupId, group] of Object.entries(GEMINI_CLI_GROUPS)) {
if (group.models.includes(modelId)) {
return { groupId, label: group.label };
function normalizeStringValue(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function normalizeNumberValue(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
/**
* Check if model should be ignored
*/
function shouldIgnoreModel(modelId: string): boolean {
return IGNORED_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix));
function resolveGeminiCliTierId(payload: GeminiCliCodeAssistResponse | null): string | null {
if (!payload) return null;
const currentTier = payload.currentTier ?? payload.current_tier;
const paidTier = payload.paidTier ?? payload.paid_tier;
const rawId = normalizeStringValue(paidTier?.id) ?? normalizeStringValue(currentTier?.id);
return rawId ? rawId.toLowerCase() : null;
}
function resolveGeminiCliTierLabel(payload: GeminiCliCodeAssistResponse | null): string | null {
const tierId = resolveGeminiCliTierId(payload);
if (!tierId) return null;
return GEMINI_CLI_TIER_LABELS[tierId] ?? tierId;
}
function resolveGeminiCliCreditBalance(payload: GeminiCliCodeAssistResponse | null): number | null {
if (!payload) return null;
const paidTier = payload.paidTier ?? payload.paid_tier;
const currentTier = payload.currentTier ?? payload.current_tier;
const tier = paidTier ?? currentTier;
if (!tier) return null;
const credits = tier.availableCredits ?? tier.available_credits ?? [];
let total = 0;
let found = false;
for (const credit of credits) {
const creditType = normalizeStringValue(credit.creditType ?? credit.credit_type);
if (creditType !== GEMINI_CLI_G1_CREDIT_TYPE) continue;
const amount = normalizeNumberValue(credit.creditAmount ?? credit.credit_amount);
if (amount !== null) {
total += amount;
found = true;
}
}
return found ? total : null;
}
async function fetchGeminiCliSupplementary(
accessToken: string,
projectId: string,
verbose: boolean
): Promise<GeminiCliSupplementaryInfo> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(GEMINI_CLI_CODE_ASSIST_URL, {
method: 'POST',
signal: controller.signal,
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
cloudaicompanionProject: projectId,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
duetProject: projectId,
},
}),
});
clearTimeout(timeoutId);
if (!response.ok) {
if (verbose) {
console.error(`[i] Gemini CLI supplementary metadata unavailable: HTTP ${response.status}`);
}
return { tierLabel: null, tierId: null, creditBalance: null };
}
const payload = (await response.json()) as GeminiCliCodeAssistResponse;
return {
tierLabel: resolveGeminiCliTierLabel(payload),
tierId: resolveGeminiCliTierId(payload),
creditBalance: resolveGeminiCliCreditBalance(payload),
};
} catch (error) {
clearTimeout(timeoutId);
if (verbose) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`[i] Gemini CLI supplementary metadata skipped: ${message}`);
}
return { tierLabel: null, tierId: null, creditBalance: null };
}
}
function buildGeminiCliFailureResult(
@@ -271,6 +371,9 @@ function buildGeminiCliFailureResult(
success: false,
buckets: [],
projectId,
tierLabel: null,
tierId: null,
creditBalance: null,
lastUpdated: Date.now(),
accountId,
error: options.error,
@@ -480,81 +583,38 @@ function buildGeminiCliHttpFailureResult(
* Groups buckets by model series and token type
*/
function buildGeminiCliBuckets(rawBuckets: RawGeminiCliBucket[]): GeminiCliBucket[] {
// Group buckets by groupId::tokenType
const grouped = new Map<
string,
{
label: string;
tokenType: string | null;
remainingFraction: number;
resetTime: string | null;
modelIds: string[];
}
>();
const parsedBuckets = rawBuckets
.map((bucket): GeminiCliParsedBucket | null => {
const modelId = normalizeStringValue(bucket.model_id ?? bucket.modelId);
if (!modelId) return null;
for (const bucket of rawBuckets) {
const modelId = bucket.model_id || bucket.modelId || '';
if (!modelId) continue;
const tokenType = normalizeStringValue(bucket.token_type ?? bucket.tokenType);
const remainingFractionRaw = normalizeNumberValue(
bucket.remaining_fraction ?? bucket.remainingFraction
);
const remainingAmount = normalizeNumberValue(
bucket.remaining_amount ?? bucket.remainingAmount
);
const resetTime = normalizeStringValue(bucket.reset_time ?? bucket.resetTime);
// Skip ignored models
if (shouldIgnoreModel(modelId)) continue;
const tokenType = bucket.token_type ?? bucket.tokenType ?? null;
// Clamp remainingFraction to [0, 1] range
const rawRemainingFraction = bucket.remaining_fraction ?? bucket.remainingFraction ?? 1;
const remainingFraction = Math.max(0, Math.min(1, rawRemainingFraction));
const resetTime = bucket.reset_time ?? bucket.resetTime ?? null;
// Find group for this model
const group = findModelGroup(modelId);
const groupId = group?.groupId || 'other';
const label = group?.label || 'Other Models';
// Create compound key for grouping
const key = `${groupId}::${tokenType || 'combined'}`;
const existing = grouped.get(key);
if (existing) {
// Merge: take the minimum remaining fraction (most limiting)
existing.remainingFraction = Math.min(existing.remainingFraction, remainingFraction);
// Keep earliest reset time if available
if (resetTime && (!existing.resetTime || resetTime < existing.resetTime)) {
existing.resetTime = resetTime;
let fallbackFraction: number | null = null;
if (remainingAmount !== null) {
fallbackFraction = remainingAmount <= 0 ? 0 : null;
} else if (resetTime) {
fallbackFraction = 0;
}
existing.modelIds.push(modelId);
} else {
grouped.set(key, {
label,
return {
modelId,
tokenType,
remainingFraction,
remainingFraction: remainingFractionRaw ?? fallbackFraction ?? 1,
remainingAmount,
resetTime,
modelIds: [modelId],
});
}
}
};
})
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
// Convert to array
const buckets: GeminiCliBucket[] = [];
for (const [key, data] of grouped.entries()) {
buckets.push({
id: key,
label: data.label,
tokenType: data.tokenType,
remainingFraction: data.remainingFraction,
remainingPercent: Math.round(data.remainingFraction * 100),
resetTime: data.resetTime,
modelIds: data.modelIds,
});
}
// Sort by label then token type
buckets.sort((a, b) => {
const labelCompare = a.label.localeCompare(b.label);
if (labelCompare !== 0) return labelCompare;
return (a.tokenType || '').localeCompare(b.tokenType || '');
});
return buckets;
return buildGeminiCliBucketsFromParsedBuckets(parsedBuckets);
}
/**
@@ -577,12 +637,16 @@ async function fetchWithAuthData(
});
}
const url = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERSION}:retrieveUserQuota`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const supplementaryPromise = fetchGeminiCliSupplementary(
authData.accessToken,
authData.projectId,
verbose
);
try {
const response = await fetch(url, {
const response = await fetch(GEMINI_CLI_QUOTA_URL, {
method: 'POST',
signal: controller.signal,
headers: {
@@ -609,6 +673,7 @@ async function fetchWithAuthData(
const data = (await response.json()) as GeminiCliQuotaResponse;
const rawBuckets = data.buckets || [];
const buckets = buildGeminiCliBuckets(rawBuckets);
const supplementary = await supplementaryPromise;
if (verbose) console.error(`[i] Gemini CLI buckets found: ${buckets.length}`);
@@ -616,6 +681,9 @@ async function fetchWithAuthData(
success: true,
buckets,
projectId: authData.projectId,
tierLabel: supplementary.tierLabel,
tierId: supplementary.tierId,
creditBalance: supplementary.creditBalance,
lastUpdated: Date.now(),
accountId,
};
+8
View File
@@ -176,6 +176,8 @@ export interface GeminiCliBucket {
remainingFraction: number;
/** Remaining quota as percentage (0-100) */
remainingPercent: number;
/** Remaining quota count when the upstream API provides it */
remainingAmount?: number | null;
/** ISO timestamp when quota resets, null if unknown */
resetTime: string | null;
/** Model IDs in this bucket */
@@ -192,6 +194,12 @@ export interface GeminiCliQuotaResult extends QuotaErrorMetadata {
buckets: GeminiCliBucket[];
/** GCP project ID for this account */
projectId: string | null;
/** Human-readable Gemini tier label when available */
tierLabel?: string | null;
/** Stable Gemini tier identifier when available */
tierId?: string | null;
/** Available Google One AI credits when reported by the API */
creditBalance?: number | null;
/** Timestamp of fetch */
lastUpdated: number;
/** Error message if fetch failed */
+11
View File
@@ -15,7 +15,9 @@ import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator';
import { CLIProxyProvider } from '../types';
import { CompositeTierConfig } from '../../config/unified-config-types';
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector';
import { prepareImageAnalysisFallbackHook } from '../../utils/hooks';
import { getEffectiveApiKey } from '../auth-token-manager';
import { warn } from '../../utils/ui';
import { normalizeModelIdForProvider } from '../model-id-normalizer';
@@ -155,10 +157,12 @@ export function createSettingsFile(
try {
ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
}
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
// Inject Image Analyzer hooks into variant settings
ensureImageAnalyzerHooks({
@@ -166,6 +170,7 @@ export function createSettingsFile(
profileType: 'cliproxy',
cliproxyProvider: provider,
settingsPath,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
return settingsPath;
@@ -194,10 +199,12 @@ export function createSettingsFileUnified(
try {
ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
}
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
// Inject Image Analyzer hooks into variant settings
ensureImageAnalyzerHooks({
@@ -205,6 +212,7 @@ export function createSettingsFileUnified(
profileType: 'cliproxy',
cliproxyProvider: provider,
settingsPath,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
return settingsPath;
@@ -297,16 +305,19 @@ export function createCompositeSettingsFile(
if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) {
try {
ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
}
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
ensureImageAnalyzerHooks({
profileName: `composite-${name}`,
profileType: 'cliproxy',
cliproxyProvider: tiers[defaultTier].provider,
isComposite: true,
settingsPath,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
}
+11 -1
View File
@@ -638,15 +638,25 @@ function displayGeminiCliQuotaSection(
if (quota.projectId) {
console.log(` Project: ${dim(quota.projectId)}`);
}
if (quota.tierLabel) {
console.log(` Tier: ${dim(quota.tierLabel)}`);
}
if (quota.creditBalance !== null && quota.creditBalance !== undefined) {
console.log(` Credits: ${dim(quota.creditBalance.toLocaleString())}`);
}
for (const bucket of quota.buckets) {
const bar = formatQuotaBar(bucket.remainingPercent);
const tokenLabel = bucket.tokenType ? dim(` (${bucket.tokenType})`) : '';
const amountLabel =
bucket.remainingAmount !== null && bucket.remainingAmount !== undefined
? dim(` ${bucket.remainingAmount.toLocaleString()} left`)
: '';
const resetLabel = bucket.resetTime
? dim(` Resets ${formatResetTimeISO(bucket.resetTime)}`)
: '';
console.log(
` ${bucket.label.padEnd(24)} ${bar} ${bucket.remainingPercent.toFixed(0)}%${tokenLabel}${resetLabel}`
` ${bucket.label.padEnd(24)} ${bar} ${bucket.remainingPercent.toFixed(0)}%${tokenLabel}${amountLabel}${resetLabel}`
);
}
console.log('');
+329
View File
@@ -0,0 +1,329 @@
import { COPILOT_SUBCOMMANDS } from '../copilot/constants';
import { CURSOR_SUBCOMMANDS } from '../cursor/constants';
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
export type HelpTopicName = 'profiles' | 'providers' | 'completion' | 'targets';
export interface HelpTopicEntry {
name: HelpTopicName;
summary: string;
}
export interface RootCommandEntry {
name: string;
summary: string;
group: 'start' | 'manage' | 'runtime' | 'operations';
visibility: 'public' | 'hidden';
aliases?: readonly string[];
}
export interface ShortcutEntry {
name: string;
summary: string;
}
export const ROOT_HELP_TOPICS: readonly HelpTopicEntry[] = [
{ name: 'profiles', summary: 'Account profiles, API profiles, and CLIProxy variants' },
{ name: 'providers', summary: 'Built-in OAuth providers and runtime shortcuts' },
{ name: 'completion', summary: 'Shell completion install, refresh, and testing' },
{ name: 'targets', summary: 'Claude, Droid, and Codex target routing' },
] as const;
export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
{
name: 'help',
summary: 'Show root help or help for a topic/command',
group: 'start',
aliases: ['--help', '-h'],
visibility: 'public',
},
{
name: 'version',
summary: 'Show version and install details',
group: 'start',
aliases: ['--version', '-v'],
visibility: 'public',
},
{
name: 'setup',
summary: 'Run the first-time setup wizard',
group: 'start',
aliases: ['--setup'],
visibility: 'public',
},
{
name: 'config',
summary: 'Open the dashboard and config subcommands',
group: 'start',
visibility: 'public',
},
{
name: 'doctor',
summary: 'Run health checks and diagnostics',
group: 'start',
aliases: ['--doctor'],
visibility: 'public',
},
{
name: 'auth',
summary: 'Manage concurrent Claude accounts',
group: 'manage',
visibility: 'public',
},
{
name: 'api',
summary: 'Create, discover, copy, export, and import API profiles',
group: 'manage',
visibility: 'public',
},
{
name: 'cliproxy',
summary: 'Manage CLIProxy variants, quota, and local lifecycle',
group: 'manage',
visibility: 'public',
},
{
name: 'env',
summary: 'Export profile env for third-party tools',
group: 'manage',
visibility: 'public',
},
{
name: 'persist',
summary: 'Write profile setup to ~/.claude/settings.json',
group: 'manage',
visibility: 'public',
},
{ name: 'tokens', summary: 'Manage CLIProxy auth tokens', group: 'manage', visibility: 'public' },
{
name: 'migrate',
summary: 'Move legacy JSON config to unified YAML config',
group: 'manage',
aliases: ['--migrate'],
visibility: 'public',
},
{
name: 'cursor',
summary: 'Run or manage the Cursor bridge',
group: 'runtime',
visibility: 'public',
},
{
name: 'copilot',
summary: 'Run or manage the GitHub Copilot bridge',
group: 'runtime',
visibility: 'public',
},
{
name: 'docker',
summary: 'Deploy or operate the bundled Docker stack',
group: 'operations',
visibility: 'public',
},
{
name: 'sync',
summary: 'Sync delegation commands and skills',
group: 'operations',
aliases: ['--sync'],
visibility: 'public',
},
{
name: 'update',
summary: 'Update CCS to the latest version',
group: 'operations',
aliases: ['--update'],
visibility: 'public',
},
{
name: 'cleanup',
summary: 'Remove old CLIProxy logs',
group: 'operations',
aliases: ['--cleanup'],
visibility: 'public',
},
{
name: '--shell-completion',
summary: 'Install shell completion',
group: 'operations',
aliases: ['-sc'],
visibility: 'hidden',
},
{
name: '--install',
summary: 'Post-install bootstrap hook',
group: 'operations',
visibility: 'hidden',
},
{
name: '--uninstall',
summary: 'Post-uninstall cleanup hook',
group: 'operations',
visibility: 'hidden',
},
{
name: '__complete',
summary: 'Hidden shell completion backend',
group: 'operations',
visibility: 'hidden',
},
] as const;
export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PROVIDER_IDS.map(
(name) => ({
name,
summary:
{
gemini: 'Google Gemini via CLIProxy OAuth',
codex: 'OpenAI Codex via CLIProxy OAuth',
agy: 'Antigravity via CLIProxy OAuth',
qwen: 'Qwen Code via CLIProxy OAuth',
iflow: 'iFlow via CLIProxy OAuth',
kiro: 'Kiro via CLIProxy OAuth',
ghcp: 'GitHub Copilot via CLIProxy OAuth',
claude: 'Claude via CLIProxy OAuth',
kimi: 'Kimi via CLIProxy OAuth',
}[name] || 'CLIProxy OAuth provider',
})
);
export const ROOT_PROFILE_EXAMPLES: readonly ShortcutEntry[] = [
{ name: 'ccs auth create work', summary: 'Create a concurrent Claude account profile' },
{ name: 'ccs api create --preset glm', summary: 'Create a GLM-backed API profile' },
{
name: 'ccs api create --preset anthropic --1m',
summary: 'Create a Claude API profile with explicit [1m]',
},
{
name: 'ccs env <profile> --format claude-extension --ide vscode',
summary: 'Export IDE extension settings',
},
] as const;
export const ROOT_COMPATIBLE_ALIAS_EXAMPLES: readonly ShortcutEntry[] = [
{ name: '--target claude|droid|codex', summary: 'Route a profile to the target runtime' },
{ name: 'ccs-droid / ccsd', summary: 'Explicit Droid runtime aliases' },
{ name: 'ccs-codex / ccsx / ccsxp', summary: 'Explicit Codex runtime aliases' },
] as const;
export const ROOT_COMMAND_FLAGS = [
'--help',
'-h',
'--version',
'-v',
'--shell-completion',
'-sc',
] as const;
export const AUTH_SUBCOMMANDS = [
'create',
'backup',
'list',
'show',
'remove',
'default',
'reset-default',
] as const;
export const API_SUBCOMMANDS = [
'create',
'list',
'discover',
'copy',
'export',
'import',
'remove',
] as const;
export const CLIPROXY_SUBCOMMANDS = [
'create',
'edit',
'list',
'remove',
'catalog',
'sync',
'quota',
'start',
'restart',
'status',
'stop',
'doctor',
'default',
'pause',
'resume',
] as const;
export const CONFIG_SUBCOMMANDS = ['auth', 'channels', 'image-analysis', 'thinking'] as const;
export const DOCKER_SUBCOMMANDS = ['up', 'down', 'status', 'update', 'logs', 'config'] as const;
export const TOKENS_FLAGS = [
'--show',
'--api-key',
'--secret',
'--regenerate-secret',
'--variant',
'--reset',
'--help',
'-h',
] as const;
export const MIGRATE_FLAGS = ['--dry-run', '--rollback', '--list-backups', '--help'] as const;
export const CLEANUP_FLAGS = [
'--errors',
'--days=',
'--dry-run',
'--force',
'--help',
'-h',
] as const;
export const PROVIDER_FLAGS = [
'--auth',
'--add',
'--paste-callback',
'--accounts',
'--use',
'--config',
'--thinking',
'--effort',
'--1m',
'--no-1m',
'--logout',
'--headless',
'--port-forward',
'--help',
'-h',
] as const;
export const COMMAND_FLAG_SUGGESTIONS: Readonly<Record<string, readonly string[]>> = {
'--shell-completion': ['--bash', '--zsh', '--fish', '--powershell', '--force', '-f'],
auth: ['--help', '-h'],
api: ['--help', '-h'],
cleanup: CLEANUP_FLAGS,
config: ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'],
cursor: ['--help', '-h'],
doctor: ['--fix', '-f', '--help', '-h'],
docker: ['--help', '-h', '--host'],
env: ['--format', '--shell', '--ide', '--help', '-h'],
migrate: MIGRATE_FLAGS,
tokens: TOKENS_FLAGS,
update: ['--force', '--beta', '--dev', '--help', '-h'],
};
export const CURSOR_COMPLETION_SUBCOMMANDS = [...CURSOR_SUBCOMMANDS] as const;
export const COPILOT_COMPLETION_SUBCOMMANDS = [...COPILOT_SUBCOMMANDS, 'help'] as const;
export function getPublicRootCommands(): readonly RootCommandEntry[] {
return ROOT_COMMAND_CATALOG.filter((entry) => entry.visibility === 'public');
}
export function getAllRootCommandTokens(): string[] {
return uniqueStrings(
ROOT_COMMAND_CATALOG.flatMap((entry) => [entry.name, ...(entry.aliases || [])])
);
}
export function getPublicRootCommandTokens(): string[] {
return uniqueStrings(
ROOT_COMMAND_CATALOG.filter((entry) => entry.visibility === 'public').flatMap((entry) => [
entry.name,
...(entry.aliases || []),
])
);
}
export function uniqueStrings(values: readonly string[]): string[] {
return [...new Set(values.filter(Boolean))].sort((left, right) => left.localeCompare(right));
}
+266
View File
@@ -0,0 +1,266 @@
import ProfileDetector from '../auth/profile-detector';
import {
API_SUBCOMMANDS,
AUTH_SUBCOMMANDS,
BUILTIN_PROVIDER_SHORTCUTS,
CLEANUP_FLAGS,
COMMAND_FLAG_SUGGESTIONS,
CONFIG_SUBCOMMANDS,
COPILOT_COMPLETION_SUBCOMMANDS,
DOCKER_SUBCOMMANDS,
ROOT_COMMAND_FLAGS,
ROOT_HELP_TOPICS,
TOKENS_FLAGS,
PROVIDER_FLAGS,
uniqueStrings,
getPublicRootCommandTokens,
CLIPROXY_SUBCOMMANDS,
MIGRATE_FLAGS,
CURSOR_COMPLETION_SUBCOMMANDS,
} from './command-catalog';
export interface CompletionSuggestion {
value: string;
description?: string;
}
type CompletionShell = 'bash' | 'zsh' | 'fish' | 'powershell';
interface CompletionRequest {
current: string;
shell: CompletionShell;
tokensBeforeCurrent: string[];
}
function suggestion(value: string, description?: string): CompletionSuggestion {
return { value, description };
}
function filterSuggestions(
suggestions: readonly CompletionSuggestion[],
current: string
): CompletionSuggestion[] {
const needle = current.trim().toLowerCase();
const deduped = [...new Map(suggestions.map((entry) => [entry.value, entry])).values()];
if (!needle) return deduped.sort((left, right) => left.value.localeCompare(right.value));
return deduped
.filter((entry) => entry.value.toLowerCase().startsWith(needle))
.sort((left, right) => left.value.localeCompare(right.value));
}
function getDynamicProfileSuggestions(): CompletionSuggestion[] {
const detector = new ProfileDetector();
const profiles = detector.getAllProfiles();
return uniqueStrings([
...profiles.settings,
...profiles.accounts,
...profiles.cliproxyVariants,
]).map((value) => suggestion(value));
}
function getTopLevelSuggestions(): CompletionSuggestion[] {
return [
...getPublicRootCommandTokens().map((value) => suggestion(value)),
...ROOT_COMMAND_FLAGS.map((value) => suggestion(value)),
...BUILTIN_PROVIDER_SHORTCUTS.map((entry) => suggestion(entry.name, entry.summary)),
...getDynamicProfileSuggestions(),
];
}
function getProfileDetector(): ProfileDetector {
return new ProfileDetector();
}
function getProfileNames(type: 'settings' | 'accounts' | 'cliproxyVariants'): string[] {
return getProfileDetector().getAllProfiles()[type];
}
function completeSubcommands(
values: readonly string[],
flags: readonly string[] = ['--help', '-h']
): CompletionSuggestion[] {
return uniqueStrings([...values, ...flags]).map((value) => suggestion(value));
}
function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSuggestion[] {
const [command, subcommand] = tokensBeforeCurrent;
const lastToken = tokensBeforeCurrent[tokensBeforeCurrent.length - 1];
switch (command) {
case undefined:
return getTopLevelSuggestions();
case 'help':
return completeSubcommands(ROOT_HELP_TOPICS.map((topic) => topic.name));
case 'auth':
if (!subcommand) return completeSubcommands(AUTH_SUBCOMMANDS);
if (subcommand === 'backup')
return completeSubcommands(['default', ...getProfileNames('accounts')], ['--json']);
if (subcommand === 'show')
return completeSubcommands(getProfileNames('accounts'), ['--json']);
if (subcommand === 'remove')
return completeSubcommands(getProfileNames('accounts'), ['--yes', '-y']);
if (subcommand === 'default') return completeSubcommands(getProfileNames('accounts'));
if (subcommand === 'create') {
return completeSubcommands(
[],
['--force', '--share-context', '--context-group', '--deeper-continuity', '--bare']
);
}
if (subcommand === 'list') return completeSubcommands([], ['--verbose', '--json']);
return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS.auth);
case 'api':
if (!subcommand) return completeSubcommands(API_SUBCOMMANDS);
if (subcommand === 'create') {
return completeSubcommands(
[],
[
'--preset',
'--cliproxy-provider',
'--base-url',
'--api-key',
'--model',
'--1m',
'--no-1m',
'--target',
'--force',
'--yes',
'-y',
]
);
}
if (subcommand === 'discover')
return completeSubcommands([], ['--register', '--json', '--force']);
if (subcommand === 'copy')
return completeSubcommands(getProfileNames('settings'), [
'--target',
'--force',
'--yes',
'-y',
]);
if (subcommand === 'export')
return completeSubcommands(getProfileNames('settings'), ['--out', '--include-secrets']);
if (subcommand === 'import')
return completeSubcommands([], ['--name', '--yes', '-y', '--force']);
if (subcommand === 'remove')
return completeSubcommands(getProfileNames('settings'), ['--yes', '-y']);
return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS.api);
case 'cliproxy':
if (!subcommand)
return completeSubcommands(CLIPROXY_SUBCOMMANDS, [
'--install',
'--latest',
'--update',
'--backend',
'--verbose',
'-v',
'--help',
'-h',
]);
if (['remove', 'edit'].includes(subcommand)) {
return completeSubcommands(getProfileNames('cliproxyVariants'), ['--yes', '-y']);
}
if (subcommand === 'create' && lastToken === '--provider') {
return BUILTIN_PROVIDER_SHORTCUTS.map((entry) => suggestion(entry.name, entry.summary));
}
return completeSubcommands(
[],
['--provider', '--model', '--target', '--backend', '--force', '--yes', '-y', '--help', '-h']
);
case 'config':
if (!subcommand || subcommand.startsWith('-')) {
return completeSubcommands(CONFIG_SUBCOMMANDS, COMMAND_FLAG_SUGGESTIONS.config);
}
return [];
case 'docker':
if (!subcommand || subcommand.startsWith('-')) {
return completeSubcommands(DOCKER_SUBCOMMANDS, COMMAND_FLAG_SUGGESTIONS.docker);
}
if (subcommand === 'up')
return completeSubcommands([], ['--port', '--proxy-port', '--host', '--help', '-h']);
if (subcommand === 'logs')
return completeSubcommands([], ['--follow', '--service', '--host', '--help', '-h']);
return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS.docker);
case 'cursor':
return completeSubcommands(CURSOR_COMPLETION_SUBCOMMANDS);
case 'copilot':
return completeSubcommands(COPILOT_COMPLETION_SUBCOMMANDS);
case 'env':
if (lastToken === '--format')
return completeSubcommands(['openai', 'anthropic', 'raw', 'claude-extension']);
if (lastToken === '--shell')
return completeSubcommands(['auto', 'bash', 'zsh', 'fish', 'powershell']);
if (lastToken === '--ide') return completeSubcommands(['vscode', 'cursor', 'windsurf']);
return completeSubcommands(
uniqueStrings([
...getProfileNames('settings'),
...getProfileNames('accounts'),
...BUILTIN_PROVIDER_SHORTCUTS.map((entry) => entry.name),
]),
COMMAND_FLAG_SUGGESTIONS.env
);
case 'tokens':
if (lastToken === '--variant')
return completeSubcommands(getProfileNames('cliproxyVariants'));
return completeSubcommands([], TOKENS_FLAGS);
case 'migrate':
return completeSubcommands([], MIGRATE_FLAGS);
case 'cleanup':
return completeSubcommands([], CLEANUP_FLAGS);
case '--shell-completion':
case '-sc':
return completeSubcommands(
[],
['--bash', '--zsh', '--fish', '--powershell', '--force', '-f']
);
default:
if (BUILTIN_PROVIDER_SHORTCUTS.some((entry) => entry.name === command)) {
if (command === 'agy')
return completeSubcommands([], [...PROVIDER_FLAGS, '--accept-agr-risk']);
if (command === 'kiro') {
return completeSubcommands(
[],
[...PROVIDER_FLAGS, '--kiro-auth-method', '--import', '--incognito']
);
}
return completeSubcommands([], PROVIDER_FLAGS);
}
return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS[command] || []);
}
}
export function getCompletionSuggestions(request: CompletionRequest): CompletionSuggestion[] {
return filterSuggestions(getSuggestionsForCommand(request.tokensBeforeCurrent), request.current);
}
function parseCompletionArgs(args: string[]): CompletionRequest {
const shellIndex = args.indexOf('--shell');
const currentIndex = args.indexOf('--current');
const separatorIndex = args.indexOf('--');
const shell = (shellIndex !== -1 ? args[shellIndex + 1] : 'bash') as CompletionShell;
const current = currentIndex !== -1 ? args[currentIndex + 1] || '' : '';
const tokensBeforeCurrent = separatorIndex === -1 ? [] : args.slice(separatorIndex + 1);
return { shell, current, tokensBeforeCurrent };
}
function formatForShell(
shell: CompletionShell,
suggestions: readonly CompletionSuggestion[]
): string[] {
if (shell === 'fish' || shell === 'powershell') {
return suggestions.map((entry) =>
entry.description ? `${entry.value}\t${entry.description}` : entry.value
);
}
return suggestions.map((entry) => entry.value);
}
export async function handleCompletionCommand(args: string[]): Promise<void> {
try {
const request = parseCompletionArgs(args);
const suggestions = getCompletionSuggestions(request);
process.stdout.write(formatForShell(request.shell, suggestions).join('\n'));
} catch {
// Completion must fail closed and quietly so shell TAB does not surface stack traces.
}
}
+29 -7
View File
@@ -1,5 +1,6 @@
import type { CursorAuthStatus, CursorDaemonStatus, CursorModel } from '../cursor/types';
import type { CursorConfig } from '../config/unified-config-types';
import { getCcsDirDisplay } from '../utils/config-manager';
import { color } from '../utils/ui';
function printLines(lines: string[]): void {
@@ -12,7 +13,7 @@ export function renderCursorHelp(): number {
printLines([
'Cursor IDE Integration',
'',
'Usage: ccs cursor <subcommand> [options]',
'Usage: ccs cursor <subcommand>',
'',
'Subcommands:',
' auth Import Cursor IDE authentication token',
@@ -24,6 +25,9 @@ export function renderCursorHelp(): number {
' disable Disable cursor integration in unified config',
' help Show this help message',
'',
'Runtime entry:',
' ccs cursor [claude args] # Run Claude via the local Cursor proxy',
'',
'Auth options:',
' ccs cursor auth # Auto-detect from Cursor SQLite',
' ccs cursor auth --manual --token <t> --machine-id <id>',
@@ -32,6 +36,8 @@ export function renderCursorHelp(): number {
' 1. ccs cursor enable # Enable integration',
' 2. ccs cursor auth # Import Cursor IDE token',
' 3. ccs cursor start # Start daemon',
' 4. ccs cursor "task" # Run Claude through Cursor',
' 5. ccs cursor status # Inspect auth/daemon wiring',
'',
'Or use the web UI: ccs config -> Cursor page',
'',
@@ -45,6 +51,11 @@ export function renderCursorStatus(
authStatus: CursorAuthStatus,
daemonStatus: CursorDaemonStatus
): void {
const localBaseUrl = `http://127.0.0.1:${cursorConfig.port}`;
const dirDisplay = getCcsDirDisplay();
const isReady =
cursorConfig.enabled && authStatus.authenticated && !authStatus.expired && daemonStatus.running;
console.log('Cursor IDE Status');
console.log('─────────────────');
console.log('');
@@ -77,15 +88,25 @@ export function renderCursorStatus(
console.log(` Ghost mode: ${cursorConfig.ghost_mode ? 'On' : 'Off'}`);
console.log('');
if (
cursorConfig.enabled &&
authStatus.authenticated &&
!authStatus.expired &&
daemonStatus.running
) {
console.log('Runtime:');
console.log(` OpenAI base: ${localBaseUrl}/v1`);
console.log(` Anthropic base: ${localBaseUrl}`);
console.log(` Chat route: ${localBaseUrl}/v1/chat/completions`);
console.log(` Messages route: ${localBaseUrl}/v1/messages`);
console.log(` Models route: ${localBaseUrl}/v1/models`);
console.log('');
console.log('Client setup:');
console.log(` Raw settings: ${dirDisplay}/cursor.settings.json`);
console.log(' Runtime entry: ccs cursor [claude args]');
console.log(' Status command: ccs cursor status');
console.log(' Help command: ccs cursor help');
if (isReady) {
return;
}
console.log('');
console.log('Next steps:');
if (!cursorConfig.enabled) {
console.log(' - Enable: ccs cursor enable');
@@ -96,6 +117,7 @@ export function renderCursorStatus(
if (!daemonStatus.running) {
console.log(' - Start: ccs cursor start');
}
console.log(' - Help: ccs cursor help');
}
export function renderCursorModels(models: CursorModel[], defaultModel: string): void {
+2 -15
View File
@@ -1,7 +1,7 @@
/**
* Cursor CLI Command
*
* Handles `ccs cursor <subcommand>` commands.
* Handles `ccs cursor [subcommand]` commands.
*/
import {
@@ -20,20 +20,6 @@ import { DEFAULT_CURSOR_CONFIG } from '../config/unified-config-types';
import { renderCursorHelp, renderCursorModels, renderCursorStatus } from './cursor-command-display';
import { ok, fail, info } from '../utils/ui';
/** Valid cursor subcommands — imported by ccs.ts for routing */
export const CURSOR_SUBCOMMANDS = [
'auth',
'status',
'models',
'start',
'stop',
'enable',
'disable',
'help',
'--help',
'-h',
] as const;
/**
* Handle cursor subcommand.
*/
@@ -56,6 +42,7 @@ export async function handleCursorCommand(args: string[]): Promise<number> {
case 'disable':
return handleDisable();
case undefined:
return handleHelp();
case 'help':
case '--help':
case '-h':
+196 -663
View File
@@ -1,696 +1,229 @@
import * as fs from 'fs';
import * as path from 'path';
import { initUI, box, color, dim, sectionHeader, subheader } from '../utils/ui';
import { isUnifiedMode } from '../config/unified-config-loader';
import { getCcsDir, getCcsDirSource } from '../utils/config-manager';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import { getOfficialChannelsSupportMessage } from '../channels/official-channels-runtime';
import packageJson from '../../package.json';
import { color, dim, header, initUI, subheader } from '../utils/ui';
import {
BUILTIN_PROVIDER_SHORTCUTS,
ROOT_COMMAND_CATALOG,
ROOT_COMPATIBLE_ALIAS_EXAMPLES,
ROOT_HELP_TOPICS,
ROOT_PROFILE_EXAMPLES,
getPublicRootCommands,
type HelpTopicName,
type RootCommandEntry,
} from './command-catalog';
type HelpWriter = (line: string) => void;
// Get version from package.json (same as version-command.ts)
const VERSION = JSON.parse(
fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8')
).version;
function getTopicSummary(name: HelpTopicName): string {
return ROOT_HELP_TOPICS.find((topic) => topic.name === name)?.summary || '';
}
/**
* Print a major section with ═══ borders (only for 3 main sections)
* Format:
* ═══ TITLE ═══
* Subtitle line 1
* Subtitle line 2
*
* command Description
*/
function printMajorSection(
function writeCommandTable(
title: string,
subtitles: string[],
items: [string, string][],
writeLine: HelpWriter = console.log
entries: readonly { name: string; summary: string }[],
writeLine: HelpWriter
): void {
// Section header with ═══ borders
writeLine(sectionHeader(title));
// Subtitles on separate lines (dim)
for (const subtitle of subtitles) {
writeLine(` ${dim(subtitle)}`);
writeLine(subheader(title));
const maxWidth = Math.max(...entries.map((entry) => entry.name.length));
for (const entry of entries) {
writeLine(` ${color(entry.name.padEnd(maxWidth + 2), 'command')} ${entry.summary}`);
}
// Empty line before items
writeLine('');
// Calculate max command length for alignment
const maxCmdLen = Math.max(...items.map(([cmd]) => cmd.length));
for (const [cmd, desc] of items) {
const paddedCmd = cmd.padEnd(maxCmdLen + 2);
writeLine(` ${color(paddedCmd, 'command')} ${desc}`);
}
// Extra spacing after section
writeLine('');
}
/**
* Print a sub-section with colored title
* Format:
* Title (context):
* command Description
*/
function printSubSection(
title: string,
items: [string, string][],
writeLine: HelpWriter = console.log
): void {
// Sub-section header (colored, no borders)
writeLine(subheader(`${title}:`));
function writeGroupedCommands(group: RootCommandEntry['group'], writeLine: HelpWriter): void {
const entries = getPublicRootCommands()
.filter((entry) => entry.group === group)
.map((entry) => ({ name: entry.name, summary: entry.summary }));
// Calculate max command length for alignment
const maxCmdLen = Math.max(...items.map(([cmd]) => cmd.length));
const titles: Record<RootCommandEntry['group'], string> = {
start: 'Start Here',
manage: 'Profile Management',
operations: 'Operations',
runtime: 'Compatible Runtimes',
};
for (const [cmd, desc] of items) {
const paddedCmd = cmd.padEnd(maxCmdLen + 2);
writeLine(` ${color(paddedCmd, 'command')} ${desc}`);
}
writeCommandTable(titles[group], entries, writeLine);
}
// Spacing after section
async function showProfilesHelp(writeLine: HelpWriter): Promise<void> {
await initUI();
writeLine(header('CCS Profiles Help'));
writeLine('');
writeCommandTable(
'Profile Types',
[
{ name: 'ccs auth create <name>', summary: 'Concurrent Claude account profile' },
{ name: 'ccs api create', summary: 'API-backed settings profile' },
{ name: 'ccs cliproxy create <name>', summary: 'Named CLIProxy variant profile' },
{ name: 'ccs env <profile>', summary: 'Export an existing profile for other tools' },
],
writeLine
);
writeCommandTable('Examples', ROOT_PROFILE_EXAMPLES, writeLine);
writeLine(` ${dim('Deep help: ccs auth --help | ccs api --help | ccs cliproxy --help')}`);
writeLine('');
}
/**
* Print a config/paths section
* Format:
* Title:
* Label: path
*/
function printConfigSection(
title: string,
items: [string, string][],
writeLine: HelpWriter = console.log
): void {
writeLine(subheader(`${title}:`));
// Calculate max label length for alignment
const maxLabelLen = Math.max(...items.map(([label]) => label.length));
for (const [label, path] of items) {
const paddedLabel = label.padEnd(maxLabelLen);
writeLine(` ${paddedLabel} ${color(path, 'path')}`);
}
async function showProvidersHelp(writeLine: HelpWriter): Promise<void> {
await initUI();
writeLine(header('CCS Providers Help'));
writeLine('');
writeCommandTable('Built-in OAuth Providers', BUILTIN_PROVIDER_SHORTCUTS, writeLine);
writeCommandTable(
'Common Setup Paths',
[
{
name: 'ccs <provider> --auth',
summary: 'Authenticate a provider account without launching',
},
{ name: 'ccs api create --preset <id>', summary: 'Create an API-backed provider profile' },
{ name: 'ccs config', summary: 'Use the dashboard for provider and model setup' },
],
writeLine
);
writeLine(` ${dim('Deep help: ccs cliproxy --help | ccs api --help')}`);
writeLine('');
}
/**
* Display comprehensive help information for CCS (Claude Code Switch)
*/
async function showTargetsHelp(writeLine: HelpWriter): Promise<void> {
await initUI();
writeLine(header('CCS Targets Help'));
writeLine('');
writeCommandTable('Target Routing', ROOT_COMPATIBLE_ALIAS_EXAMPLES, writeLine);
writeCommandTable(
'Examples',
[
{ name: 'ccs glm --target droid', summary: 'Run a profile on Droid instead of Claude' },
{
name: 'ccs --target codex',
summary: 'Open a native Codex session with your current setup',
},
{ name: 'ccs codex-api --target codex', summary: 'Run a routed API bridge on native Codex' },
],
writeLine
);
}
export async function handleHelpCommand(writeLine: HelpWriter = console.log): Promise<void> {
// Initialize UI (if not already)
await initUI();
// Hero box with ASCII art logo and config hint
// Each letter: C=╔═╗/║ /╚═╝, C=╔═╗/║ /╚═╝, S=╔═╗/╚═╗/╚═╝
const logo = `
╔═╗ ╔═╗ ╔═╗
║ ║ ╚═╗ v${VERSION}
╚═╝ ╚═╝ ╚═╝
Claude Code Profile & Model Switcher
Run ${color('ccs config', 'command')} for web dashboard`.trim();
writeLine(
box(logo, {
padding: 1,
borderStyle: 'round',
titleAlignment: 'center',
})
);
writeLine(header(`CCS CLI v${packageJson.version}`));
writeLine('');
writeLine(' Claude profile switching, provider routing, and compatible runtime bridges.');
writeLine('');
// Resolve display path for dynamic sections
const [dirSource] = getCcsDirSource();
const dirDisplay = dirSource === 'default' ? '~/.ccs' : getCcsDir();
// Usage section
writeLine(subheader('Usage:'));
writeLine(` ${color('ccs', 'command')} [profile] [claude-args...]`);
writeLine(` ${color('ccs', 'command')} [flags]`);
writeLine(subheader('Usage'));
writeLine(` ${color('ccs <profile> [claude-args...]', 'command')}`);
writeLine(` ${color('ccs <command> [options]', 'command')}`);
writeLine(` ${color('ccs help <topic>', 'command')}`);
writeLine('');
// ═══════════════════════════════════════════════════════════════════════════
// MAJOR SECTION 1: API Key Profiles
// ═══════════════════════════════════════════════════════════════════════════
printMajorSection(
'API Key Profiles',
[`Configure in ${dirDisplay}/*.settings.json`],
writeGroupedCommands('start', writeLine);
writeGroupedCommands('manage', writeLine);
writeGroupedCommands('runtime', writeLine);
writeGroupedCommands('operations', writeLine);
writeCommandTable(
'OAuth Provider Shortcuts',
BUILTIN_PROVIDER_SHORTCUTS.map((entry) => ({
name: `ccs ${entry.name}`,
summary: entry.summary,
})),
writeLine
);
writeCommandTable('Examples', ROOT_PROFILE_EXAMPLES, writeLine);
writeCommandTable('Targets and Aliases', ROOT_COMPATIBLE_ALIAS_EXAMPLES, writeLine);
writeCommandTable(
'More Help',
[
['ccs', 'Use default Claude account'],
['ccs glm', 'GLM 5 (API key required)'],
['ccs km', 'Kimi for Coding (API key)'],
[
'ccs api create --preset alibaba-coding-plan',
'Alibaba Coding Plan (Anthropic-compatible API key)',
],
['ccs ollama', 'Local Ollama (http://localhost:11434)'],
['ccs llamacpp', 'Local llama.cpp (http://127.0.0.1:8080)'],
['ccs ollama-cloud', 'Ollama Cloud (API key required)'],
['', ''], // Spacer
['ccs api create --preset anthropic', 'Anthropic direct API key (sk-ant-...)'],
[
'ccs api create --cliproxy-provider gemini',
'Create routed API profile from CLIProxy Gemini',
],
['ccs api create', 'Create custom API profile'],
['ccs api discover --register', 'Discover/register orphan settings files'],
['ccs api copy <src> <dest>', 'Duplicate API profile'],
['ccs api export <name>', 'Export profile bundle'],
['ccs api import <file>', 'Import profile bundle'],
['ccs api remove', 'Remove an API profile'],
['ccs api list', 'List all API profiles'],
{ name: 'ccs help profiles', summary: getTopicSummary('profiles') },
{ name: 'ccs help providers', summary: getTopicSummary('providers') },
{ name: 'ccs help completion', summary: getTopicSummary('completion') },
{ name: 'ccs help targets', summary: getTopicSummary('targets') },
{ name: 'ccs api --help', summary: 'Deep help for API profile lifecycle commands' },
{ name: 'ccs cliproxy --help', summary: 'Deep help for variants, quota, and lifecycle' },
{ name: 'ccs docker --help', summary: 'Deep help for Docker deployment commands' },
{ name: 'ccs cursor --help', summary: 'Deep help for Cursor runtime/admin commands' },
{ name: 'ccs copilot --help', summary: 'Deep help for GitHub Copilot commands' },
],
writeLine
);
// ═══════════════════════════════════════════════════════════════════════════
// MAJOR SECTION 2: Account Management
// ═══════════════════════════════════════════════════════════════════════════
printMajorSection(
'Account Management',
['Run multiple Claude accounts concurrently'],
[
['ccs auth --help', 'Show account management commands'],
[
'ccs auth create <name>',
'Create account profile (supports --bare, shared groups, --deeper-continuity)',
],
['ccs config', 'Dashboard: Accounts table can edit context mode/group/continuity depth'],
[
'~/.ccs/config.yaml',
'Optional: continuity.inherit_from_account maps API/CLIProxy/copilot/default profiles to an account context',
],
['ccs auth list', 'List all account profiles'],
['ccs auth default <name>', 'Set default profile'],
['ccs auth reset-default', 'Restore original CCS default'],
['ccs cliproxy auth claude', 'Alternative: authenticate Claude account pool via CLIProxy'],
],
writeLine
);
// ═══════════════════════════════════════════════════════════════════════════
// MAJOR SECTION 3: CLI Proxy (OAuth Providers)
// ═══════════════════════════════════════════════════════════════════════════
printMajorSection(
'CLI Proxy Plus (OAuth Providers)',
[
'Zero-config OAuth authentication via CLIProxy Plus',
'First run: Browser opens for authentication, then model selection',
'Settings: ~/.ccs/{provider}.settings.json (created after auth)',
'Safety: do not reuse one Google account across "ccs gemini" and "ccs agy" (issue #509)',
'Antigravity requires multi-step responsibility confirmation (issue #509)',
'If you want to keep Google AI access, do not continue this shared-account setup',
'CCS is as-is and does not take responsibility for account bans/access loss',
],
[
['ccs gemini', 'Google Gemini (gemini-2.5-pro or 3-pro)'],
['ccs codex', 'OpenAI Codex (supports -medium/-high/-xhigh model suffixes)'],
['ccs agy', 'Antigravity (Claude/Gemini models)'],
['ccs qwen', 'Qwen Code OAuth (CLIProxy)'],
['ccs kimi', 'Kimi (Moonshot AI K2/K2.5 models)'],
['ccs kiro', 'Kiro (AWS CodeWhisperer Claude models)'],
['ccs ghcp', 'GitHub Copilot (OAuth via CLIProxy Plus)'],
['', ''], // Spacer
['ccs <provider> --auth', 'Authenticate only'],
['ccs <provider> --auth --add', 'Add another account'],
[
'ccs <provider> --paste-callback',
'Show auth URL and prompt for callback paste (cross-browser)',
],
['ccs <provider> --accounts', 'List all accounts'],
['ccs <provider> --use <nickname-or-id>', 'Switch to account'],
['ccs <provider> --config', 'Change model (agy, gemini)'],
[
'ccs agy --accept-agr-risk',
'Bypass interactive Antigravity confirmation (you accept full responsibility)',
],
[
'ccs <provider> --thinking <value>',
'Set thinking budget (low/medium/high/xhigh/auto/off or number)',
],
['ccs codex --effort <level>', 'Set codex reasoning effort (minimal/low/medium/high/xhigh)'],
['ccs <provider> --1m', 'Request explicit 1M context when the selected model supports [1m]'],
['ccs <provider> --no-1m', 'Force standard context / clear [1m]'],
['ccs <provider> --logout', 'Clear authentication'],
['ccs <provider> --headless', 'Headless auth (for SSH)'],
['ccs <provider> --port-forward', 'Force port-forwarding mode (skip prompt)'],
['ccs kiro --auth --kiro-auth-method aws', 'Kiro via AWS Builder ID (device code)'],
['ccs kiro --auth --kiro-auth-method aws-authcode', 'Kiro via AWS auth code flow'],
['ccs kiro --auth --kiro-auth-method google', 'Kiro via Google OAuth'],
['ccs kiro --auth --kiro-auth-method github', 'Kiro via GitHub OAuth (Dashboard flow)'],
['ccs kiro --import', 'Import token from Kiro IDE'],
['ccs kiro --incognito', 'Use incognito browser (default: normal)'],
['ccs codex "explain code"', 'Use with prompt'],
],
writeLine
);
// ═══════════════════════════════════════════════════════════════════════════
// MAJOR SECTION 4: GitHub Copilot Integration (copilot-api)
// ═══════════════════════════════════════════════════════════════════════════
printMajorSection(
'GitHub Copilot Integration (copilot-api)',
[
'Use your GitHub Copilot subscription with Claude Code via copilot-api',
'Requires: npm install -g copilot-api',
'Note: For OAuth-based access, use ccs ghcp instead',
],
[
['ccs copilot', 'Use Copilot via copilot-api daemon'],
['ccs copilot auth', 'Authenticate with GitHub'],
['ccs copilot status', 'Show integration status'],
['ccs copilot models', 'List available models'],
['ccs copilot usage', 'Show Copilot quota usage'],
['ccs copilot start', 'Start copilot-api daemon'],
['ccs copilot stop', 'Stop copilot-api daemon'],
['ccs copilot enable', 'Enable integration'],
['ccs copilot disable', 'Disable integration'],
],
writeLine
);
// ═══════════════════════════════════════════════════════════════════════════
// MAJOR SECTION 5: Cursor IDE Integration
// ═══════════════════════════════════════════════════════════════════════════
printMajorSection(
'Cursor IDE Integration',
[
'Use Cursor IDE with Claude Code via cursor proxy daemon',
'Auto-detects token from Cursor installation',
],
[
['ccs cursor <cmd>', 'Use Cursor IDE integration'],
['ccs cursor auth', 'Import Cursor token'],
['ccs cursor auth --manual --token <t> --machine-id <id>', 'Manual token import'],
['ccs cursor status', 'Show connection status'],
['ccs cursor models', 'List available models'],
['ccs cursor start', 'Start proxy daemon'],
['ccs cursor stop', 'Stop proxy daemon'],
['ccs cursor enable', 'Enable cursor integration'],
['ccs cursor disable', 'Disable cursor integration'],
],
writeLine
);
// ═══════════════════════════════════════════════════════════════════════════
// SUB-SECTIONS (simpler styling)
// ═══════════════════════════════════════════════════════════════════════════
// Delegation
printSubSection(
'Delegation (inside Claude Code CLI)',
[
['/ccs "task"', 'Delegate task (auto-selects profile)'],
['/ccs --glm "task"', 'Force GLM-5 for simple tasks'],
['/ccs --kimi "task"', 'Force Kimi OAuth for long context'],
['/ccs --km "task"', 'Force Kimi API key for long context'],
['/ccs:continue "follow-up"', 'Continue last delegation session'],
],
writeLine
);
// Delegation CLI Flags (Claude Code passthrough)
printSubSection(
'Delegation Flags (Claude Code passthrough)',
[
['--max-turns <n>', 'Limit agentic turns (prevents loops)'],
['--fallback-model <model>', 'Auto-fallback on overload (sonnet)'],
['--agents <json>', 'Inject dynamic subagents'],
['--betas <features>', 'Enable experimental features'],
['--allowedTools <list>', 'Restrict available tools'],
['--disallowedTools <list>', 'Block specific tools'],
],
writeLine
);
// Diagnostics
printSubSection(
'Diagnostics',
[
['ccs setup', 'First-time setup wizard'],
['ccs doctor', 'Run health check and diagnostics'],
['ccs cleanup', 'Remove old CLIProxy logs'],
['ccs config', 'Open web dashboard (includes Claude IDE Extension setup page)'],
['ccs config auth setup', 'Configure dashboard login'],
['ccs config auth show', 'Show dashboard auth status'],
['ccs config channels', 'Show Official Channels status'],
[
'ccs config channels --set telegram,discord',
'Auto-add Telegram + Discord on supported native Claude runs',
],
['ccs config channels --set-token telegram=<token>', 'Save TELEGRAM_BOT_TOKEN'],
['ccs config image-analysis', 'Show image analysis settings'],
['ccs config image-analysis --enable', 'Enable image analysis'],
['ccs config thinking', 'Show thinking/reasoning settings'],
['ccs config thinking --mode auto', 'Set thinking mode'],
['ccs config thinking --clear-provider-override codex', 'Clear provider overrides'],
['ccs config --port 3000', 'Use specific port'],
['ccs config --host 0.0.0.0', 'Force all-interface binding for remote devices'],
['ccs persist <profile>', 'Write profile setup to ~/.claude/settings.json'],
['ccs persist --list-backups', 'List available settings.json backups'],
['ccs persist --restore', 'Restore settings.json from latest backup'],
['ccs sync', 'Sync delegation commands and skills'],
['ccs update', 'Update CCS to latest version'],
['ccs update --force', 'Force reinstall current version'],
['ccs update --beta', 'Install from dev channel (unstable)'],
],
writeLine
);
// Environment export
printSubSection(
'Environment Export',
[
['ccs env <profile>', 'Export env vars for third-party tools'],
['ccs env <profile> --format openai', 'OpenAI-compatible vars (OpenCode/Cursor)'],
['ccs env <profile> --format anthropic', 'Anthropic vars (default)'],
['ccs env <profile> --format raw', 'All effective env vars'],
[
'ccs env <profile> --format claude-extension --ide vscode',
'VS Code/Cursor Claude extension settings JSON',
],
[
'ccs env <profile> --format claude-extension --ide windsurf',
'Windsurf Claude extension settings JSON',
],
['ccs env <profile> --shell fish', 'Fish shell syntax'],
],
writeLine
);
// Flags
printSubSection(
'Flags',
[
['--config-dir <path>', 'Use custom CCS config directory'],
['--target <cli>', 'Target CLI: claude (default), droid, codex (runtime-only)'],
['-h, --help', 'Show this help message'],
['-v, --version', 'Show version and installation info'],
['-sc, --shell-completion', 'Install shell auto-completion'],
],
writeLine
);
// Aliases
printSubSection(
'Aliases',
[
['ccs-droid <profile> [args]', 'Explicit Droid runtime alias'],
['ccsd <profile> [args]', 'Legacy shortcut for: ccs-droid <profile> [args]'],
['ccs-codex <profile> [args]', 'Explicit Codex runtime alias'],
['ccsx <profile> [args]', 'Short alias for: ccs-codex <profile> [args]'],
['ccsxp [args]', 'Shortcut for: ccs codex --target codex [args]'],
],
writeLine
);
// Multi-target examples
printSubSection(
'Multi-Target',
[
['ccs glm --target droid', 'Run GLM profile on Droid CLI'],
['ccs-droid glm', 'Same as above (explicit alias)'],
['ccsd glm', 'Legacy shortcut for ccs-droid'],
['ccs --target codex', 'Open a native Codex session with your existing ~/.codex setup'],
['ccs-codex', 'Same as above (explicit Codex alias)'],
['ccsx', 'Short alias for ccs-codex'],
['ccsxp "your prompt"', 'Run built-in CLIProxy Codex on native Codex CLI'],
['ccs codex --target codex', 'Explicit form of ccsxp'],
[
'ccs api create codex-api --cliproxy-provider codex',
'Create a routed API bridge that can also run on Codex',
],
['ccs codex-api --target codex', 'Run a Codex bridge profile on native Codex CLI'],
['ccs-droid codex', 'Run built-in CLIProxy Codex profile on Droid'],
['ccs-droid agy', 'Run built-in CLIProxy Antigravity profile on Droid'],
[
'ccs-droid codex exec --skip-permissions-unsafe "fix failing tests"',
'Pass through Droid exec mode',
],
[
'ccs-droid codex -m custom:gpt-5.3-codex "fix failing tests"',
'Auto-routes short exec flags',
],
[
'ccs-droid codex --skip-permissions-unsafe "fix failing tests"',
'Auto-routes to Droid exec when exec-only flags are detected',
],
[
'ccs cliproxy create my-codex --provider codex --target droid',
'Create CLIProxy variant with Droid as default target',
],
['ccs glm', 'Run GLM profile on Claude Code (default)'],
],
writeLine
);
printSubSection(
'Codex + CLIProxy',
[
['ccsxp "your prompt"', 'Use the built-in CCS Codex provider shortcut on native Codex'],
['ccs config', 'Open Compatible -> Codex CLI for native Codex setup and diagnostics'],
[
'Default provider',
'Set to cliproxy if plain codex or a personal cxp alias should use CLIProxy',
],
['Model provider auth', 'Save cliproxy with env_key = "CLIPROXY_API_KEY"'],
],
writeLine
);
// Configuration
printConfigSection(
'Configuration',
[
['Config File:', isUnifiedMode() ? `${dirDisplay}/config.yaml` : `${dirDisplay}/config.json`],
['Profiles:', `${dirDisplay}/profiles.json`],
['Instances:', `${dirDisplay}/instances/`],
['Settings:', `${dirDisplay}/*.settings.json`],
],
writeLine
);
// CLI Proxy management
printSubSection(
'CLI Proxy Plus Management',
[
['ccs cliproxy', 'Show CLIProxy Plus status and version'],
['ccs cliproxy --help', 'Full CLIProxy Plus management help'],
['ccs cliproxy doctor', 'Quota diagnostics (Antigravity)'],
['ccs cliproxy --install <ver>', 'Install specific version (e.g., 6.6.6)'],
['ccs cliproxy --latest', 'Update to latest version'],
['', ''], // Spacer
['ccs cliproxy pause <p> <a>', 'Pause account from rotation'],
['ccs cliproxy resume <p> <a>', 'Resume paused account'],
['ccs cliproxy status', 'Show CLIProxy process status'],
['ccs cliproxy quota', 'Show quota/tier/pause status for all providers'],
['ccs cliproxy quota --provider <name>', 'Show quota/tier/pause status for one provider'],
],
writeLine
);
printSubSection(
'Docker Deployment',
[
['ccs docker --help', 'Manage the integrated CCS + CLIProxy Docker stack'],
['ccs docker up', 'Build and start the stack locally'],
['ccs docker up --host <ssh>', 'Stage assets to ~/.ccs/docker and deploy remotely'],
['ccs docker status', 'Show docker compose and supervisor status'],
['ccs docker logs --follow', 'Tail combined CCS + CLIProxy logs'],
['ccs docker update', 'Update CCS and CLIProxy inside the running container'],
['ccs docker config', 'Show bundled asset paths and deployment defaults'],
],
writeLine
);
// CLI Proxy configuration flags (new)
printSubSection(
'CLI Proxy Configuration',
[
['--proxy-host <host>', 'Remote proxy hostname/IP'],
['--proxy-port <port>', `Proxy port (default: ${CLIPROXY_DEFAULT_PORT})`],
['--proxy-protocol <proto>', 'Protocol: http or https (default: http)'],
['--proxy-auth-token <token>', 'Auth token for remote proxy'],
['--proxy-timeout <ms>', 'Connection timeout in ms (default: 2000)'],
['--local-proxy', 'Force local mode, ignore remote config'],
['--remote-only', 'Fail if remote unreachable (no fallback)'],
['--allow-self-signed', 'Allow self-signed certs (for dev proxies)'],
],
writeLine
);
// W3: Thinking Budget explanation
printSubSection(
'Extended Thinking / Reasoning',
[
['--thinking off', 'Disable extended thinking'],
['--thinking auto', 'Let model decide dynamically'],
['--thinking low', '1K tokens - Quick responses'],
['--thinking medium', '8K tokens - Standard analysis'],
['--thinking high', '24K tokens - Deep reasoning'],
['--thinking xhigh', '32K tokens - Maximum depth'],
['--thinking <number>', 'Custom token budget (512-100000)'],
['', ''],
['--effort <level>', 'Codex alias for reasoning effort (minimal/low/medium/high/xhigh)'],
['--effort xhigh', 'Pin Codex effort to xhigh for this run'],
['', ''],
['Droid exec:', 'Use native Droid flag: --reasoning-effort <level>'],
['', 'CCS auto-maps --thinking/--effort to --reasoning-effort in droid exec mode.'],
['', 'For interactive droid sessions, CCS applies reasoning via Droid BYOK model config.'],
['', 'When multiple reasoning flags are provided, the first flag wins.'],
['', ''],
['Note:', 'Extended thinking allocates compute for step-by-step reasoning'],
['', 'before responding.'],
['', 'Providers: agy/gemini use --thinking, codex uses --effort (or --thinking alias).'],
['', 'Codex model suffixes also pin effort: -medium / -high / -xhigh.'],
],
writeLine
);
// Extended Context (1M)
printSubSection(
'Extended Context (--1m)',
[
['--1m', 'Request 1M token context when the selected model supports [1m]'],
['--no-1m', 'Force standard context (Claude default stays plain)'],
['', ''],
['Auto behavior:', 'Gemini models: CCS auto-adds [1m] when supported'],
['', 'Claude models: plain by default, opt-in with --1m or saved [1m]'],
['', ''],
['Note:', 'CCS only controls the saved [1m] suffix.'],
['', 'Provider pricing and entitlement stay upstream.'],
[
'',
'Some accounts/providers can still return 429 extra-usage errors for long-context requests.',
],
],
writeLine
);
// Image Analysis
printSubSection(
'Image Analysis (CLIProxy vision)',
[
['ccs config image-analysis', 'Show current settings'],
['ccs config image-analysis --enable', 'Enable for CLIProxy providers'],
['ccs config image-analysis --disable', 'Disable (use native Read)'],
['ccs config image-analysis --timeout 120', 'Set analysis timeout'],
['ccs config image-analysis --set-model <p> <m>', 'Set provider model'],
['', ''],
['Note:', 'When enabled, images/PDFs are analyzed via vision models'],
['', 'instead of passing raw data to Claude. Works with CLIProxy'],
['', 'providers (agy, gemini, codex, kiro, ghcp).'],
],
writeLine
);
printSubSection(
'Official Channels (official Claude plugins)',
[
['ccs config', 'Dashboard -> Settings -> Channels (fastest path)'],
['ccs config channels', 'Show current status'],
[
'ccs config channels --set telegram,discord',
'Auto-add selected channels on native Claude default/account sessions',
],
['ccs config channels --set all', 'Enable Telegram, Discord, and iMessage'],
['ccs config channels --unattended', 'Also add --dangerously-skip-permissions'],
['ccs config channels --set-token telegram=<token>', 'Save TELEGRAM_BOT_TOKEN'],
['ccs config channels --set-token discord=<token>', 'Save DISCORD_BOT_TOKEN'],
['ccs config channels --clear-token [channel]', 'Remove one or all saved channel tokens'],
['', ''],
['', 'Fastest path: turn on the channel, save the token if needed, then run ccs.'],
['Note:', getOfficialChannelsSupportMessage()],
['', 'Telegram/Discord tokens live in ~/.claude/channels/<channel>/.env.'],
['', 'Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work for that launch.'],
['', 'iMessage is macOS-only and requires local OS permissions instead of a bot token.'],
],
writeLine
);
// CCS Environment Variables
printSubSection(
'Environment Variables',
[
['CCS_DIR', 'Override CCS config directory (default: ~/.ccs)'],
['CCS_HOME', 'Override home directory (legacy, appends .ccs)'],
['CCS_DEBUG', 'Enable debug logging'],
['CCS_THINKING', 'Override thinking level (flag > env > config)'],
],
writeLine
);
// CLI Proxy env vars
printSubSection(
'CLI Proxy Environment Variables',
[
['CCS_PROXY_HOST', 'Remote proxy hostname'],
['CCS_PROXY_PORT', 'Proxy port'],
['CCS_PROXY_PROTOCOL', 'Protocol (http/https)'],
['CCS_PROXY_AUTH_TOKEN', 'Auth token'],
['CCS_PROXY_TIMEOUT', 'Connection timeout in ms'],
['CCS_PROXY_FALLBACK_ENABLED', 'Enable local fallback (1/0)'],
['CCS_ALLOW_SELF_SIGNED', 'Allow self-signed certs (1/0)'],
],
writeLine
);
// CLI Proxy paths
writeLine(subheader('CLI Proxy:'));
writeLine(` Binary: ${color(`${dirDisplay}/cliproxy/bin/cli-proxy-api-plus`, 'path')}`);
writeLine(` Config: ${color(`${dirDisplay}/cliproxy/config.yaml`, 'path')}`);
writeLine(` Auth: ${color(`${dirDisplay}/cliproxy/auth/`, 'path')}`);
writeLine(` ${dim(`Port: ${CLIPROXY_DEFAULT_PORT} (default)`)}`);
writeLine('');
// Shared Data
writeLine(subheader('Shared Data:'));
writeLine(` Commands: ${color(`${dirDisplay}/shared/commands/`, 'path')}`);
writeLine(` Skills: ${color(`${dirDisplay}/shared/skills/`, 'path')}`);
writeLine(` Agents: ${color(`${dirDisplay}/shared/agents/`, 'path')}`);
writeLine(` ${dim('Note: Symlinked across all profiles')}`);
writeLine('');
// Examples (aligned with consistent spacing)
writeLine(subheader('Examples:'));
writeLine(` $ ${color('ccs', 'command')} ${dim('# Use default account')}`);
writeLine(
` $ ${color('ccs gemini', 'command')} ${dim('# OAuth (browser opens first time)')}`
);
writeLine(` $ ${color('ccs glm "implement API"', 'command')} ${dim('# API key model')}`);
writeLine(` $ ${color('ccs config', 'command')} ${dim('# Open web dashboard')}`);
writeLine('');
// Update examples
writeLine(subheader('Update:'));
writeLine(
` $ ${color('ccs update', 'command')} ${dim('# Update to latest stable')}`
);
writeLine(
` $ ${color('ccs update --force', 'command')} ${dim('# Force reinstall current')}`
);
writeLine(` $ ${color('ccs update --beta', 'command')} ${dim('# Install dev channel')}`);
writeLine('');
// Docs link
writeLine(` ${dim('Docs: https://github.com/kaitranntt/ccs')}`);
writeLine('');
// Uninstall
writeLine(subheader('Uninstall:'));
writeLine(` ${color('npm uninstall -g @kaitranntt/ccs', 'command')}`);
writeLine('');
// License
writeLine(dim('License: MIT'));
writeLine(` ${dim('Flags: -h/--help, -v/--version, -sc/--shell-completion, --target <cli>')}`);
writeLine('');
}
function listHelpTargets(): string {
return ROOT_HELP_TOPICS.map((topic) => topic.name).join(', ');
}
export async function handleHelpRoute(
args: string[],
writeLine: HelpWriter = console.log
): Promise<void> {
const topic = args[0] as HelpTopicName | undefined;
if (!topic) {
await handleHelpCommand(writeLine);
return;
}
if (topic === 'profiles') {
await showProfilesHelp(writeLine);
return;
}
if (topic === 'providers') {
await showProvidersHelp(writeLine);
return;
}
if (topic === 'targets') {
await showTargetsHelp(writeLine);
return;
}
if (topic === 'completion') {
const { showShellCompletionHelp } = await import('./shell-completion-command');
showShellCompletionHelp(writeLine);
return;
}
const commandHandlers: Partial<Record<string, () => Promise<void>>> = {
api: async () => (await import('./api-command/help')).showApiCommandHelp(writeLine),
auth: async () => {
const authModule = await import('../auth/auth-commands');
const AuthCommands = authModule.default;
await new AuthCommands().showHelp();
},
cleanup: async () => (await import('./cleanup-command')).handleCleanupCommand(['--help']),
cliproxy: async () => (await import('./cliproxy/help-subcommand')).showHelp(),
copilot: async () =>
process.exit(await (await import('./copilot-command')).handleCopilotCommand(['--help'])),
cursor: async () =>
process.exit(await (await import('./cursor-command')).handleCursorCommand(['--help'])),
docker: async () => (await import('./docker/help-subcommand')).showHelp(),
migrate: async () => (await import('./migrate-command')).printMigrateHelp(),
setup: async () => (await import('./setup-command')).handleSetupCommand(['--help']),
tokens: async () =>
process.exit(await (await import('./tokens-command')).handleTokensCommand(['--help'])),
};
const handler = commandHandlers[topic];
if (handler) {
await handler();
return;
}
await initUI();
writeLine(color(`Unknown help topic or command: ${topic}`, 'error'));
writeLine('');
writeLine(` ${dim(`Available help topics: ${listHelpTargets()}`)}`);
writeLine('');
process.exitCode = 1;
}
export function getRootHelpVisibleCommands(): string[] {
return getPublicRootCommands().map((entry) => entry.name);
}
export function getRootHelpCatalogEntries(): readonly RootCommandEntry[] {
return ROOT_COMMAND_CATALOG;
}
+18 -3
View File
@@ -6,6 +6,8 @@
import { info, ok, color, box, initUI } from '../utils/ui';
import { uninstallWebSearchHook, uninstallWebSearchMcp } from '../utils/websearch';
import { uninstallImageAnalysisMcp } from '../utils/image-analysis';
import { uninstallImageAnalyzerHook } from '../utils/hooks';
import { ClaudeSymlinkManager } from '../utils/claude-symlink-manager';
/**
@@ -49,18 +51,31 @@ export async function handleUninstallCommand(): Promise<void> {
removed += 1;
}
// 3. Remove symlinks from ~/.claude/
// 3. Remove Image Analysis hook fallback + managed MCP runtime
const imageHookRemoved = uninstallImageAnalyzerHook();
if (imageHookRemoved) {
console.log(ok('Removed Image Analysis hook fallback'));
removed += 1;
}
const imageMcpRemoved = uninstallImageAnalysisMcp();
if (imageMcpRemoved) {
console.log(ok('Removed Image Analysis MCP runtime'));
removed += 1;
}
// 4. Remove symlinks from ~/.claude/
const symlinkManager = new ClaudeSymlinkManager();
const symlinksRemoved = symlinkManager.uninstall();
removed += symlinksRemoved; // Add actual count of symlinks removed
// 4. Summary
// 5. Summary
console.log('');
if (removed > 0) {
console.log(ok('Uninstall complete!'));
console.log('');
console.log(info('~/.ccs/ directory preserved'));
console.log(info('To reinstall: ccs --install'));
console.log(info('To reinstall: npm install -g @kaitranntt/ccs --force'));
} else {
console.log(info('Nothing to uninstall'));
}
+11 -11
View File
@@ -16,7 +16,7 @@ async function printUpdateCommandHelp(): Promise<void> {
console.log('');
}
const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
{
name: 'migrate',
aliases: ['--migrate'],
@@ -55,9 +55,9 @@ const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
{
name: 'help',
aliases: ['--help', '-h'],
handle: async () => {
const { handleHelpCommand } = await import('./help-command');
await handleHelpCommand();
handle: async (args) => {
const { handleHelpRoute } = await import('./help-command');
await handleHelpRoute(args);
},
},
{
@@ -82,6 +82,13 @@ const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
await handleShellCompletionCommand(args);
},
},
{
name: '__complete',
handle: async (args) => {
const { handleCompletionCommand } = await import('./completion-backend');
await handleCompletionCommand(args);
},
},
{
name: 'doctor',
aliases: ['--doctor'],
@@ -172,13 +179,6 @@ const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
await handleSetupCommand(args);
},
},
{
name: 'cursor',
handle: async (args) => {
const { handleCursorCommand } = await import('./cursor-command');
process.exit(await handleCursorCommand(args));
},
},
];
export async function tryHandleRootCommand(args: string[]): Promise<boolean> {
+26 -11
View File
@@ -28,6 +28,24 @@ interface ShellCompletionInstallerLike {
install(shell: ShellTarget, options: { force: boolean }): ShellCompletionInstallResult;
}
export function showShellCompletionHelp(writeLine: (line: string) => void = console.log): void {
writeLine(header('Shell Completion'));
writeLine('');
writeLine(color('Usage:', 'info'));
writeLine(' ccs --shell-completion # Auto-detect shell and install');
writeLine(' ccs --shell-completion --bash # Install for bash');
writeLine(' ccs --shell-completion --zsh # Install for zsh');
writeLine(' ccs --shell-completion --fish # Install for fish');
writeLine(' ccs --shell-completion --powershell # Install for PowerShell');
writeLine(' ccs --shell-completion --force # Reinstall/refresh the active shell setup');
writeLine('');
writeLine(color('Test:', 'info'));
writeLine(' ccs <TAB>');
writeLine(' ccs help <TAB>');
writeLine(' ccs auth <TAB>');
writeLine('');
}
export function parseShellCompletionArgs(args: string[]): ShellCompletionParsedArgs {
let targetShell: ShellTarget = null;
const force = args.includes('--force') || args.includes('-f');
@@ -77,12 +95,15 @@ export function createShellCompletionCommandContract(
*/
export async function handleShellCompletionCommand(args: string[]): Promise<void> {
await initUI();
const { ShellCompletionInstaller } = await import('../utils/shell-completion');
console.log(header('Shell Completion Installer'));
console.log('');
if (args.includes('--help') || args.includes('-h')) {
showShellCompletionHelp();
return;
}
try {
const { ShellCompletionInstaller } = await import('../utils/shell-completion');
console.log(header('Shell Completion Installer'));
console.log('');
const installer = new ShellCompletionInstaller();
const contract = createShellCompletionCommandContract(installer);
await runCommandWithContract(args, contract);
@@ -90,13 +111,7 @@ export async function handleShellCompletionCommand(args: string[]): Promise<void
const err = error as Error;
console.error(fail(`Error: ${err.message}`));
console.error('');
console.error(color('Usage:', 'warning'));
console.error(' ccs --shell-completion # Auto-detect shell');
console.error(' ccs --shell-completion --bash # Install for bash');
console.error(' ccs --shell-completion --zsh # Install for zsh');
console.error(' ccs --shell-completion --fish # Install for fish');
console.error(' ccs --shell-completion --powershell # Install for PowerShell');
console.error('');
showShellCompletionHelp(console.error);
process.exit(1);
}
}
+48 -7
View File
@@ -9,6 +9,7 @@ import { spawn } from 'child_process';
import { CopilotConfig } from '../config/unified-config-types';
import { getGlobalEnvConfig } from '../config/unified-config-loader';
import { ensureCliproxyService } from '../cliproxy';
import { getEffectiveApiKey } from '../cliproxy/auth-token-manager';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth';
import { isDaemonRunning, startDaemon } from './copilot-daemon';
@@ -22,13 +23,24 @@ import {
createWebSearchTraceContext,
syncWebSearchMcpToConfigDir,
} from '../utils/websearch-manager';
import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../utils/hooks';
import {
appendThirdPartyImageAnalysisToolArgs,
ensureImageAnalysisMcpOrThrow,
syncImageAnalysisMcpToConfigDir,
} from '../utils/image-analysis';
import {
applyImageAnalysisRuntimeOverrides,
getImageAnalysisHookEnv,
resolveImageAnalysisRuntimeConnection,
resolveImageAnalysisRuntimeStatus,
} from '../utils/hooks';
import { stripClaudeCodeEnv } from '../utils/shell-executor';
interface CopilotImageAnalysisDeps {
ensureCliproxyService: typeof ensureCliproxyService;
getImageAnalysisHookEnv: typeof getImageAnalysisHookEnv;
resolveImageAnalysisRuntimeStatus: typeof resolveImageAnalysisRuntimeStatus;
getLocalRuntimeApiKey: typeof getEffectiveApiKey;
}
interface CopilotImageAnalysisResolution {
@@ -96,6 +108,7 @@ export async function resolveCopilotImageAnalysisEnv(
ensureCliproxyService,
getImageAnalysisHookEnv,
resolveImageAnalysisRuntimeStatus,
getLocalRuntimeApiKey: getEffectiveApiKey,
...deps,
};
@@ -141,7 +154,20 @@ export async function resolveCopilotImageAnalysisEnv(
}
}
return { env, warning: null };
const runtimeConnection = resolveImageAnalysisRuntimeConnection();
return {
env: applyImageAnalysisRuntimeOverrides(env, {
backendId: status.backendId,
model: status.model,
runtimePath: status.runtimePath,
baseUrl: runtimeConnection.baseUrl,
apiKey: runtimeConnection.proxyTarget.isRemote
? runtimeConnection.apiKey
: resolvedDeps.getLocalRuntimeApiKey(),
allowSelfSigned: runtimeConnection.allowSelfSigned,
}),
warning: null,
};
}
/**
@@ -231,11 +257,25 @@ export async function executeCopilotProfile(
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
const globalEnvConfig = getGlobalEnvConfig();
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
syncWebSearchMcpToConfigDir(claudeConfigDir);
syncImageAnalysisMcpToConfigDir(claudeConfigDir);
// Merge with current environment (global env first, copilot overrides, then hook env vars)
const webSearchEnv = getWebSearchHookEnv();
const { env: imageAnalysisEnv, warning: imageAnalysisWarning } =
await resolveCopilotImageAnalysisEnv();
const imageAnalysisResolution = await resolveCopilotImageAnalysisEnv();
const imageAnalysisProvisioningFailed =
!imageAnalysisMcpReady && imageAnalysisResolution.env.CCS_IMAGE_ANALYSIS_ENABLED === '1';
const imageAnalysisWarning = imageAnalysisProvisioningFailed
? 'ImageAnalysis MCP provisioning failed. This session will use native Read.'
: imageAnalysisResolution.warning;
const imageAnalysisEnv = imageAnalysisProvisioningFailed
? {
...imageAnalysisResolution.env,
CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1',
}
: imageAnalysisResolution.env;
const env = stripClaudeCodeEnv({
...process.env,
...globalEnv,
@@ -251,11 +291,12 @@ export async function executeCopilotProfile(
}
console.log('');
syncWebSearchMcpToConfigDir(claudeConfigDir);
// Spawn Claude CLI
return new Promise((resolve) => {
const launchArgs = appendThirdPartyWebSearchToolArgs(claudeArgs);
const imageAnalysisArgs = imageAnalysisMcpReady
? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
: claudeArgs;
const launchArgs = appendThirdPartyWebSearchToolArgs(imageAnalysisArgs);
const traceEnv = createWebSearchTraceContext({
launcher: 'copilot.executor',
args: launchArgs,
+18
View File
@@ -0,0 +1,18 @@
export const CURSOR_SUBCOMMANDS = [
'auth',
'status',
'models',
'start',
'stop',
'enable',
'disable',
'help',
'--help',
'-h',
] as const;
export function isCursorSubcommandToken(token?: string): boolean {
return (
Boolean(token) && CURSOR_SUBCOMMANDS.includes(token as (typeof CURSOR_SUBCOMMANDS)[number])
);
}
+2
View File
@@ -205,8 +205,10 @@ export async function fetchModelsFromDaemon(port: number): Promise<CursorModel[]
data += chunk;
if (data.length > MAX_BODY_SIZE) {
debugLog('Cursor daemon /v1/models body exceeded 1MB; falling back to defaults');
res.destroy();
req.destroy();
safeResolve(DEFAULT_CURSOR_MODELS);
return;
}
});
+190
View File
@@ -0,0 +1,190 @@
import { spawn } from 'child_process';
import type { CursorConfig } from '../config/unified-config-types';
import { getGlobalEnvConfig } from '../config/unified-config-loader';
import { ensureCliproxyService } from '../cliproxy';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import { fail, info, ok } from '../utils/ui';
import {
appendThirdPartyWebSearchToolArgs,
createWebSearchTraceContext,
getWebSearchHookEnv,
syncWebSearchMcpToConfigDir,
} from '../utils/websearch-manager';
import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../utils/hooks';
import { stripClaudeCodeEnv } from '../utils/shell-executor';
import { checkAuthStatus } from './cursor-auth';
import { isDaemonRunning, startDaemon } from './cursor-daemon';
interface CursorImageAnalysisResolution {
env: Record<string, string>;
warning: string | null;
}
export function generateCursorEnv(
config: CursorConfig,
claudeConfigDir?: string
): Record<string, string> {
const opusModel = config.opus_model || config.model;
const sonnetModel = config.sonnet_model || config.model;
const haikuModel = config.haiku_model || config.model;
return {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.port}`,
ANTHROPIC_AUTH_TOKEN: 'cursor-managed',
ANTHROPIC_MODEL: config.model,
ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel,
ANTHROPIC_SMALL_FAST_MODEL: haikuModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel,
DISABLE_NON_ESSENTIAL_MODEL_CALLS: '1',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
};
}
export async function resolveCursorImageAnalysisEnv(
verbose = false
): Promise<CursorImageAnalysisResolution> {
const env = getImageAnalysisHookEnv({
profileName: 'cursor',
profileType: 'cursor',
});
const provider = env['CCS_CURRENT_PROVIDER'];
if (env['CCS_IMAGE_ANALYSIS_SKIP'] === '1' || !provider) {
return { env, warning: null };
}
const status = await resolveImageAnalysisRuntimeStatus({
profileName: 'cursor',
profileType: 'cursor',
});
if (status.effectiveRuntimeMode === 'native-read') {
return {
env: {
...env,
CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1',
},
warning: `${status.effectiveRuntimeReason || `Image analysis via ${provider} is unavailable.`} This session will use native Read.`,
};
}
if (status.proxyReadiness === 'stopped') {
const ensureServiceResult = await ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose);
if (!ensureServiceResult.started) {
return {
env: {
...env,
CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1',
},
warning: `Image analysis via ${provider} is unavailable because CCS could not start the local CLIProxy service. This session will use native Read.`,
};
}
}
return { env, warning: null };
}
export async function executeCursorProfile(
config: CursorConfig,
claudeArgs: string[],
claudeConfigDir?: string,
claudeCliPath = 'claude'
): Promise<number> {
if (!config.enabled) {
console.error(fail('Cursor integration is not enabled.'));
console.error('');
console.error('Enable it first: ccs cursor enable');
return 1;
}
const authStatus = checkAuthStatus();
if (!authStatus.authenticated) {
console.error(fail('Cursor credentials not found.'));
console.error('');
console.error('Authenticate first: ccs cursor auth');
return 1;
}
if (authStatus.expired) {
console.error(fail('Cursor credentials have expired.'));
console.error('');
console.error('Refresh them with: ccs cursor auth');
return 1;
}
let daemonRunning = await isDaemonRunning(config.port);
if (!daemonRunning) {
if (config.auto_start) {
console.log(info('Starting cursor daemon...'));
const result = await startDaemon({
port: config.port,
ghost_mode: config.ghost_mode,
});
if (!result.success) {
console.error(fail(`Failed to start cursor daemon: ${result.error}`));
return 1;
}
console.log(ok(`Daemon started on port ${config.port}`));
daemonRunning = true;
} else {
console.error(fail('Cursor daemon is not running.'));
console.error('');
console.error('Start the daemon:');
console.error(' ccs cursor start');
console.error('Or enable auto_start in the Cursor config section.');
return 1;
}
}
const cursorEnv = generateCursorEnv(config, claudeConfigDir);
const globalEnvConfig = getGlobalEnvConfig();
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
const webSearchEnv = getWebSearchHookEnv();
const { env: imageAnalysisEnv, warning: imageAnalysisWarning } =
await resolveCursorImageAnalysisEnv();
const env = stripClaudeCodeEnv({
...process.env,
...globalEnv,
...cursorEnv,
...webSearchEnv,
...imageAnalysisEnv,
CCS_PROFILE_TYPE: 'cursor',
});
console.log(info(`Using Cursor proxy (model: ${config.model})`));
if (imageAnalysisWarning) {
console.log(info(imageAnalysisWarning));
}
console.log('');
syncWebSearchMcpToConfigDir(claudeConfigDir);
return new Promise((resolve) => {
const launchArgs = appendThirdPartyWebSearchToolArgs(claudeArgs);
const traceEnv = createWebSearchTraceContext({
launcher: 'cursor.executor',
args: launchArgs,
profile: 'cursor',
profileType: 'cursor',
claudeConfigDir,
});
const proc = spawn(claudeCliPath, launchArgs, {
stdio: 'inherit',
env: { ...env, ...traceEnv },
shell: process.platform === 'win32',
});
proc.on('close', (code) => {
resolve(code ?? 0);
});
proc.on('error', (err) => {
console.error(fail(`Failed to start Claude: ${err.message}`));
resolve(1);
});
});
}
+368 -84
View File
@@ -1,14 +1,42 @@
/**
* OpenAI to Cursor Request Translator
* Converts OpenAI messages to Cursor format
* Converts OpenAI messages to Cursor format.
*/
import type { CursorMessage, CursorToolResult, CursorTool } from './cursor-protobuf-schema.js';
import type { CursorMessage, CursorTool } from './cursor-protobuf-schema.js';
interface OpenAITextPart {
type: 'text';
text?: string;
}
interface OpenAIToolUsePart {
type: 'tool_use';
id?: string;
name?: string;
input?: Record<string, unknown>;
}
interface OpenAIToolResultPart {
type: 'tool_result';
tool_use_id?: string;
content?: unknown;
}
interface OpenAIUnknownPart {
type: string;
[key: string]: unknown;
}
type OpenAIContentPart =
| OpenAITextPart
| OpenAIToolUsePart
| OpenAIToolResultPart
| OpenAIUnknownPart;
/** OpenAI message format */
interface OpenAIMessage {
role: string;
content: string | Array<{ type: string; text?: string }>;
content: string | OpenAIContentPart[];
name?: string;
tool_call_id?: string;
tool_calls?: Array<{
@@ -18,113 +46,375 @@ interface OpenAIMessage {
}>;
}
/** OpenAI request body */
interface OpenAIRequestBody {
messages: OpenAIMessage[];
tools?: CursorTool[];
reasoning_effort?: string;
}
const MAX_TOOL_RESULT_CHARS = 12_000;
const TOOL_RESULT_SERIALIZATION_FALLBACK = '[unserializable content]';
const TOOL_USE_ARGUMENTS_FALLBACK = '{}';
const TOOL_CALL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
function isTextPart(part: OpenAIContentPart): part is OpenAITextPart {
return part.type === 'text';
}
function isToolUsePart(part: OpenAIContentPart): part is OpenAIToolUsePart {
return part.type === 'tool_use';
}
function isToolResultPart(part: OpenAIContentPart): part is OpenAIToolResultPart {
return part.type === 'tool_result';
}
function extractTextContent(content: OpenAIMessage['content'], separator = ''): string {
if (typeof content === 'string') {
return content;
}
const parts: string[] = [];
for (const part of content) {
if (isTextPart(part) && part.text) {
parts.push(part.text);
}
}
return parts.join(separator);
}
function stringifyUnknown(value: unknown, fallback = ''): string {
try {
const serialized = JSON.stringify(value);
return typeof serialized === 'string' ? serialized : fallback;
} catch {
return fallback;
}
}
function sanitizeToolResultText(text: string): string {
return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
}
function truncateToolResultText(text: string): string {
if (text.length <= MAX_TOOL_RESULT_CHARS) {
return text;
}
let omittedChars = text.length - MAX_TOOL_RESULT_CHARS;
while (true) {
const suffix = `\n[truncated ${omittedChars} chars]`;
const keepLength = Math.max(MAX_TOOL_RESULT_CHARS - suffix.length, 0);
const nextOmittedChars = text.length - keepLength;
if (nextOmittedChars === omittedChars) {
return `${text.slice(0, keepLength)}${suffix}`;
}
omittedChars = nextOmittedChars;
}
}
function escapeXml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function buildToolResultBlock(toolName: string, toolCallId: string, resultText: string): string {
// Truncate raw tool output before XML escaping so the cap reflects original content.
const cleanResult = escapeXml(truncateToolResultText(sanitizeToolResultText(resultText)));
return [
'<tool_result>',
`<tool_name>${escapeXml(toolName || 'tool')}</tool_name>`,
`<tool_call_id>${escapeXml(toolCallId)}</tool_call_id>`,
`<result>${cleanResult}</result>`,
'</tool_result>',
].join('\n');
}
function normalizeToolCallId(toolCallId: string | undefined): string {
return typeof toolCallId === 'string' ? toolCallId.split('\n')[0] : '';
}
function sanitizeToolCallId(toolCallId: string | undefined): string {
const normalizedId = normalizeToolCallId(toolCallId).trim();
if (!normalizedId) {
return '';
}
if (TOOL_CALL_ID_PATTERN.test(normalizedId)) {
return normalizedId;
}
return normalizedId.replace(/[^a-zA-Z0-9_-]/g, '');
}
function extractToolResultText(content: unknown): string {
if (content === undefined) {
return '';
}
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return content
.filter(isTextPart)
.map((part) => part.text || '')
.join('\n');
}
return stringifyUnknown(content, TOOL_RESULT_SERIALIZATION_FALLBACK);
}
function createFallbackToolUseId(messageIndex: number, partIndex: number): string {
return `toolu_cursor_fallback_${messageIndex}_${partIndex}`;
}
function resolveToolUseId(
part: OpenAIToolUsePart,
messageIndex: number,
partIndex: number
): string {
const sanitizedId = sanitizeToolCallId(part.id);
return sanitizedId || createFallbackToolUseId(messageIndex, partIndex);
}
function requireToolResultId(toolCallId: string | undefined, location: string): string {
const sanitizedId = sanitizeToolCallId(toolCallId);
if (sanitizedId) {
return sanitizedId;
}
throw new Error(`${location} must include a valid tool result id`);
}
function normalizeAssistantToolCalls(
toolCalls: NonNullable<OpenAIMessage['tool_calls']>,
messageIndex: number
): NonNullable<OpenAIMessage['tool_calls']> {
return toolCalls.map((toolCall, toolCallIndex) => ({
...toolCall,
id: sanitizeToolCallId(toolCall.id) || createFallbackToolUseId(messageIndex, toolCallIndex),
function: {
name: toolCall.function?.name || 'tool',
arguments:
typeof toolCall.function?.arguments === 'string'
? toolCall.function.arguments
: stringifyUnknown(toolCall.function?.arguments ?? {}, TOOL_USE_ARGUMENTS_FALLBACK),
},
}));
}
function rememberToolCallMeta(
toolCallMetaMap: Map<string, { name: string }>,
toolCalls: NonNullable<OpenAIMessage['tool_calls']>
): void {
for (const toolCall of toolCalls) {
const toolCallId = toolCall.id || '';
const toolName = toolCall.function?.name || 'tool';
if (!toolCallId) {
continue;
}
toolCallMetaMap.set(toolCallId, { name: toolName });
const normalizedId = normalizeToolCallId(toolCallId);
if (normalizedId && normalizedId !== toolCallId) {
toolCallMetaMap.set(normalizedId, { name: toolName });
}
}
}
function rememberToolUseParts(
toolCallMetaMap: Map<string, { name: string }>,
content: OpenAIMessage['content'],
messageIndex: number
): void {
if (!Array.isArray(content)) {
return;
}
for (let partIndex = 0; partIndex < content.length; partIndex++) {
const part = content[partIndex];
if (!isToolUsePart(part)) {
continue;
}
const toolCallId = resolveToolUseId(part, messageIndex, partIndex);
const toolName = part.name || 'tool';
toolCallMetaMap.set(toolCallId, { name: toolName });
const normalizedId = normalizeToolCallId(toolCallId);
if (normalizedId && normalizedId !== toolCallId) {
toolCallMetaMap.set(normalizedId, { name: toolName });
}
}
}
function extractToolCallsFromContent(
content: OpenAIMessage['content'],
messageIndex: number
): NonNullable<OpenAIMessage['tool_calls']> {
if (!Array.isArray(content)) {
return [];
}
return content.flatMap((part, partIndex) => {
if (!isToolUsePart(part)) {
return [];
}
return [
{
id: resolveToolUseId(part, messageIndex, partIndex),
type: 'function',
function: {
name: part.name || 'tool',
arguments: stringifyUnknown(part.input ?? {}, TOOL_USE_ARGUMENTS_FALLBACK),
},
},
];
});
}
function mergeAssistantToolCalls(
primaryToolCalls: NonNullable<OpenAIMessage['tool_calls']>,
secondaryToolCalls: NonNullable<OpenAIMessage['tool_calls']>
): NonNullable<OpenAIMessage['tool_calls']> {
const merged: NonNullable<OpenAIMessage['tool_calls']> = [];
const seenIds = new Set<string>();
for (const toolCall of [...primaryToolCalls, ...secondaryToolCalls]) {
if (seenIds.has(toolCall.id)) {
continue;
}
seenIds.add(toolCall.id);
merged.push(toolCall);
}
return merged;
}
function renderUserContent(
content: OpenAIMessage['content'],
toolCallMetaMap: Map<string, { name: string }>,
messageIndex: number
): string {
if (typeof content === 'string') {
return content;
}
const parts: string[] = [];
let textBuffer = '';
for (let partIndex = 0; partIndex < content.length; partIndex++) {
const part = content[partIndex];
if (isTextPart(part) && part.text) {
textBuffer += part.text;
continue;
}
if (!isToolResultPart(part)) {
continue;
}
if (textBuffer) {
parts.push(textBuffer);
textBuffer = '';
}
const toolCallId = requireToolResultId(
part.tool_use_id,
`messages[${messageIndex}].content[${partIndex}]`
);
const normalizedId = normalizeToolCallId(toolCallId);
const toolName =
toolCallMetaMap.get(toolCallId)?.name || toolCallMetaMap.get(normalizedId)?.name || 'tool';
parts.push(buildToolResultBlock(toolName, toolCallId, extractToolResultText(part.content)));
}
if (textBuffer) {
parts.push(textBuffer);
}
return parts.join('\n');
}
/**
* Convert OpenAI messages to Cursor format with native tool_results support
* Convert OpenAI messages to Cursor format with a safer tool-result strategy.
* - system → user with [System Instructions] prefix
* - tool → accumulate into tool_results array for next user/assistant message
* - assistant with tool_calls → keep tool_calls structure (Cursor supports it natively)
* - tool → flatten into a structured user text block for Cursor compatibility
* - assistant with tool_calls → keep tool_calls in the translated shape for metadata recovery
*/
function convertMessages(messages: OpenAIMessage[]): CursorMessage[] {
const result: CursorMessage[] = [];
let pendingToolResults: CursorToolResult[] = [];
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const toolCallMetaMap = new Map<string, { name: string }>();
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
const msg = messages[messageIndex];
if (msg.role === 'system') {
let content = '';
if (typeof msg.content === 'string') {
content = msg.content;
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === 'text' && part.text) content += part.text;
}
}
result.push({
role: 'user',
content: `[System Instructions]\n${content}`,
content: `[System Instructions]\n${extractTextContent(msg.content)}`,
});
continue;
}
if (msg.role === 'tool') {
let toolContent = '';
if (typeof msg.content === 'string') {
toolContent = msg.content;
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === 'text' && part.text) {
toolContent += part.text;
}
}
}
const toolCallId = requireToolResultId(
msg.tool_call_id,
`messages[${messageIndex}].tool_call_id`
);
const normalizedToolCallId = normalizeToolCallId(toolCallId);
const rememberedToolName =
toolCallMetaMap.get(toolCallId)?.name || toolCallMetaMap.get(normalizedToolCallId)?.name;
const toolName = msg.name || rememberedToolName || 'tool';
const toolName = msg.name || 'tool';
const toolCallId = msg.tool_call_id || '';
// Accumulate tool result
pendingToolResults.push({
tool_call_id: toolCallId,
name: toolName,
index: pendingToolResults.length,
raw_args: toolContent,
result.push({
role: 'user',
content: buildToolResultBlock(toolName, toolCallId, extractTextContent(msg.content, '\n')),
});
continue;
}
if (msg.role === 'user' || msg.role === 'assistant') {
let content = '';
if (typeof msg.content === 'string') {
content = msg.content;
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === 'text' && part.text) {
content += part.text;
}
if (msg.role === 'assistant') {
const normalizedToolCalls = normalizeAssistantToolCalls(msg.tool_calls || [], messageIndex);
const assistantToolCalls = mergeAssistantToolCalls(
normalizedToolCalls,
extractToolCallsFromContent(msg.content, messageIndex)
);
if (normalizedToolCalls.length > 0) {
rememberToolCallMeta(toolCallMetaMap, normalizedToolCalls);
}
}
rememberToolUseParts(toolCallMetaMap, msg.content, messageIndex);
// Keep tool_calls structure for assistant messages
if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) {
const assistantMsg: CursorMessage = { role: 'assistant', content: '' };
const content = extractTextContent(msg.content);
if (assistantToolCalls.length > 0) {
result.push({
role: 'assistant',
content,
tool_calls: assistantToolCalls,
});
} else if (content) {
result.push({
role: 'assistant',
content,
});
}
} else {
const content = renderUserContent(msg.content, toolCallMetaMap, messageIndex);
if (content) {
assistantMsg.content = content;
result.push({
role: 'user',
content,
});
}
assistantMsg.tool_calls = msg.tool_calls;
// Attach pending tool results to assistant message with tool_calls
if (pendingToolResults.length > 0) {
assistantMsg.tool_results = pendingToolResults;
pendingToolResults = [];
}
result.push(assistantMsg);
} else if (content || pendingToolResults.length > 0) {
const msgObj: CursorMessage = {
role: msg.role,
content: content || '',
};
// Attach pending tool results to this message
if (pendingToolResults.length > 0) {
msgObj.tool_results = pendingToolResults;
pendingToolResults = [];
}
result.push(msgObj);
}
continue;
}
// Unknown role - skip with debug warning
if (process.env.CCS_DEBUG) {
console.error(`[cursor] Unknown message role: ${msg.role}, skipping`);
}
@@ -133,10 +423,6 @@ function convertMessages(messages: OpenAIMessage[]): CursorMessage[] {
return result;
}
/**
* Transform OpenAI request to Cursor format
* Returns modified body with converted messages
*/
export function buildCursorRequest(
_model: string,
body: OpenAIRequestBody,
@@ -146,10 +432,8 @@ export function buildCursorRequest(
messages: CursorMessage[];
tools?: CursorTool[];
} {
const messages = convertMessages(body.messages || []);
return {
...body,
messages,
messages: convertMessages(body.messages || []),
};
}
+1
View File
@@ -45,3 +45,4 @@ export {
// Executor
export { CursorExecutor } from './cursor-executor';
export { executeCursorProfile, generateCursorEnv } from './cursor-profile-executor';
+105 -2
View File
@@ -15,10 +15,26 @@ import { ui, warn, info } from '../utils/ui';
import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from './executor/types';
import { StreamBuffer, formatToolVerbose } from './executor/stream-parser';
import { buildExecutionResult } from './executor/result-aggregator';
import { getCcsDir, getModelDisplayName } from '../utils/config-manager';
import { getCcsDir, getModelDisplayName, loadSettings } from '../utils/config-manager';
import { getProfileLookupCandidates } from '../utils/profile-compat';
import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor';
import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance';
import {
appendThirdPartyImageAnalysisToolArgs,
ensureImageAnalysisMcpOrThrow,
syncImageAnalysisMcpToConfigDir,
} from '../utils/image-analysis';
import {
applyImageAnalysisRuntimeOverrides,
getImageAnalysisHookEnv,
prepareImageAnalysisFallbackHook,
resolveImageAnalysisRuntimeConnection,
resolveImageAnalysisRuntimeStatus,
} from '../utils/hooks';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../utils/hooks/image-analyzer-profile-hook-injector';
import { resolveCliproxyBridgeMetadata } from '../api/services';
import { ensureCliproxyService } from '../cliproxy';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import {
appendThirdPartyWebSearchToolArgs,
appendWebSearchTrace,
@@ -105,7 +121,87 @@ export class HeadlessExecutor {
}
ensureWebSearchMcpOrThrow();
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
const settings = loadSettings(settingsPath);
const cliproxyBridge = resolveCliproxyBridgeMetadata(settings);
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
ensureImageAnalyzerHooks({
profileName: profile,
profileType: 'settings',
settingsPath,
settings,
cliproxyBridge,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({
profileName: profile,
profileType: 'settings',
settings,
cliproxyBridge,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
const runtimeConnection = resolveImageAnalysisRuntimeConnection();
let imageAnalysisEnv = getImageAnalysisHookEnv({
profileName: profile,
profileType: 'settings',
settings,
cliproxyBridge,
});
imageAnalysisEnv = applyImageAnalysisRuntimeOverrides(imageAnalysisEnv, {
backendId: imageAnalysisStatus.backendId,
model: imageAnalysisStatus.model,
runtimePath: imageAnalysisStatus.runtimePath,
baseUrl: runtimeConnection.baseUrl,
apiKey: runtimeConnection.apiKey,
allowSelfSigned: runtimeConnection.allowSelfSigned,
});
if (!imageAnalysisMcpReady) {
imageAnalysisEnv = {
...imageAnalysisEnv,
CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
}
const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER'];
if (
imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' &&
imageAnalysisProvider &&
imageAnalysisStatus.effectiveRuntimeMode === 'native-read'
) {
console.error(
info(
`${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This delegation will use native Read.`
)
);
imageAnalysisEnv = {
...imageAnalysisEnv,
CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
} else if (
imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' &&
imageAnalysisProvider &&
imageAnalysisStatus.proxyReadiness === 'stopped'
) {
const ensureServiceResult = await ensureCliproxyService(CLIPROXY_DEFAULT_PORT, false);
if (!ensureServiceResult.started) {
console.error(
warn(
`Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This delegation will use native Read.`
)
);
imageAnalysisEnv = {
...imageAnalysisEnv,
CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
}
}
// Smart slash command detection and preservation
const processedPrompt = this._processSlashCommand(enhancedPrompt);
@@ -190,7 +286,10 @@ export class HeadlessExecutor {
}
}
const launchArgs = appendThirdPartyWebSearchToolArgs(args);
const imageAnalysisArgs = imageAnalysisMcpReady
? appendThirdPartyImageAnalysisToolArgs(args)
: args;
const launchArgs = appendThirdPartyWebSearchToolArgs(imageAnalysisArgs);
const traceEnv = createWebSearchTraceContext({
launcher: 'delegation.headless-executor',
args: launchArgs,
@@ -217,6 +316,7 @@ export class HeadlessExecutor {
sessionId,
sessionMgr,
claudeConfigDir: inheritedClaudeConfigDir,
imageAnalysisEnv,
traceEnv,
});
}
@@ -235,6 +335,7 @@ export class HeadlessExecutor {
sessionId: string | null;
sessionMgr: SessionManager;
claudeConfigDir?: string;
imageAnalysisEnv?: Record<string, string>;
traceEnv?: Record<string, string>;
}
): Promise<ExecutionResult> {
@@ -246,6 +347,7 @@ export class HeadlessExecutor {
sessionId,
sessionMgr,
claudeConfigDir,
imageAnalysisEnv = {},
traceEnv = {},
} = ctx;
@@ -265,6 +367,7 @@ export class HeadlessExecutor {
...process.env,
...getClaudeLaunchEnvOverrides(),
...getWebSearchHookEnv(),
...imageAnalysisEnv,
...traceEnv,
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
CCS_PROFILE_TYPE: 'settings',
@@ -70,15 +70,17 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise<void>
if (!cliproxyAvailable) {
results.details['Image Analysis'] = {
status: 'WARN',
info: `Enabled but CLIProxy not running`,
info: 'Enabled; local CLIProxy will start on launch if needed',
};
results.warnings.push({
name: 'Image Analysis',
message: 'CLIProxy not running - image analysis will fail',
fix: 'ccs config (starts CLIProxy)',
message:
'CLIProxy not running yet - CCS will start it automatically when ImageAnalysis is used',
fix: 'Optional warm-up: ccs config',
});
console.log(` ${warn('CLIProxy:')} Not running at http://127.0.0.1:${CLIPROXY_DEFAULT_PORT}`);
console.log(` ${dim('Note:')} Start with: ccs config`);
console.log(
` ${warn('CLIProxy:')} Idle at http://127.0.0.1:${CLIPROXY_DEFAULT_PORT} (auto-start on launch)`
);
return;
}
console.log(` ${ok('CLIProxy:')} Available at http://127.0.0.1:${CLIPROXY_DEFAULT_PORT}`);
+1 -1
View File
@@ -14,7 +14,7 @@ import { DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context';
import type { AccountContextPolicy } from '../auth/account-context';
import { getCcsDir, getCcsHome } from '../utils/config-manager';
const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch']);
const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis']);
/** Options for instance creation */
export interface InstanceOptions {
+46 -26
View File
@@ -11,6 +11,7 @@ import {
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import { getProxyTarget } from '../cliproxy/proxy-target-resolver';
import { generateCopilotEnv } from '../copilot/copilot-executor';
import { generateCursorEnv } from '../cursor';
import InstanceManager from '../management/instance-manager';
import SharedManager from '../management/shared-manager';
import { expandPath } from '../utils/helpers';
@@ -97,6 +98,7 @@ function describeProfile(profileName: string, result: ProfileDetectionResult): s
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.';
if (result.type === 'cursor') return 'Cursor profile routed through the local cursor daemon.';
return 'Native Claude profile resolution.';
}
@@ -129,6 +131,12 @@ export function listClaudeExtensionProfiles(): ClaudeExtensionProfileOption[] {
} catch {
// Copilot disabled; skip from setup UI.
}
try {
detector.detectProfileType('cursor');
deduped.push('cursor');
} catch {
// Cursor disabled; skip from setup UI.
}
return deduped
.map((profileName) => createProfileOption(profileName, detector.detectProfileType(profileName)))
@@ -201,40 +209,47 @@ async function resolveExtensionEnv(
}
return generateCopilotEnv(result.copilotConfig, continuity.claudeConfigDir);
})()
: (() => {
if (!result.provider) {
throw new Error(
`Profile "${requestedProfile}" is missing CLIProxy provider metadata.`
);
}
const proxyTarget = getProxyTarget();
const port = result.port || CLIPROXY_DEFAULT_PORT;
if (proxyTarget.isRemote) {
: result.type === 'cursor'
? (() => {
if (!result.cursorConfig) {
throw new Error(`Profile "${requestedProfile}" is missing cursor configuration.`);
}
return generateCursorEnv(result.cursorConfig, continuity.claudeConfigDir);
})()
: (() => {
if (!result.provider) {
throw new Error(
`Profile "${requestedProfile}" is missing CLIProxy provider metadata.`
);
}
const proxyTarget = getProxyTarget();
const port = result.port || CLIPROXY_DEFAULT_PORT;
if (proxyTarget.isRemote) {
warnings.push(
`CLIProxy is configured for remote routing via ${proxyTarget.protocol}://${proxyTarget.host}:${proxyTarget.port}.`
);
return result.isComposite && result.compositeTiers && result.compositeDefaultTier
? getCompositeEnvVars(
result.compositeTiers,
result.compositeDefaultTier,
port,
result.settingsPath,
proxyTarget
)
: getRemoteEnvVars(result.provider, proxyTarget, result.settingsPath);
}
warnings.push(
`CLIProxy is configured for remote routing via ${proxyTarget.protocol}://${proxyTarget.host}:${proxyTarget.port}.`
'CLIProxy-backed profiles require the local or remote proxy endpoint to be reachable.'
);
return result.isComposite && result.compositeTiers && result.compositeDefaultTier
? getCompositeEnvVars(
result.compositeTiers,
result.compositeDefaultTier,
port,
result.settingsPath,
proxyTarget
result.settingsPath
)
: getRemoteEnvVars(result.provider, proxyTarget, result.settingsPath);
}
warnings.push(
'CLIProxy-backed profiles require the local or remote proxy endpoint to be reachable.'
);
return result.isComposite && result.compositeTiers && result.compositeDefaultTier
? getCompositeEnvVars(
result.compositeTiers,
result.compositeDefaultTier,
port,
result.settingsPath
)
: getEffectiveEnvVars(result.provider, port, result.settingsPath);
})();
: getEffectiveEnvVars(result.provider, port, result.settingsPath);
})();
if (result.type === 'settings' && isDeprecatedGlmtProfileName(requestedProfile)) {
const normalized = normalizeDeprecatedGlmtEnv(sortEnvRecord(env));
@@ -257,6 +272,11 @@ async function resolveExtensionEnv(
'copilot-api must stay reachable for this profile to work inside the IDE extension.'
);
}
if (result.type === 'cursor') {
warnings.push(
'The local Cursor daemon must stay reachable for this profile to work inside the IDE extension.'
);
}
if (Object.keys(env).length === 0) {
throw new Error(`Profile "${requestedProfile}" has no extension environment to export.`);
}
@@ -37,6 +37,9 @@ export function evaluateTargetRuntimeCompatibility(
if (input.profileType === 'copilot') {
return unsupported('Factory Droid does not support Copilot profiles.');
}
if (input.profileType === 'cursor') {
return unsupported('Factory Droid does not support Cursor local-proxy profiles.');
}
return { supported: true };
}
@@ -51,6 +54,10 @@ export function evaluateTargetRuntimeCompatibility(
return unsupported('Codex CLI does not support Copilot profiles.');
}
if (input.profileType === 'cursor') {
return unsupported('Codex CLI does not support Cursor local-proxy profiles.');
}
if (input.profileType === 'default') {
return { supported: true };
}
+1 -1
View File
@@ -1,4 +1,4 @@
/**
* Profile mode types used across routing and target adapters.
*/
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default';
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'cursor' | 'default';
+9
View File
@@ -102,6 +102,15 @@ export function getCcsDirSource(): [string, string] {
return [r.source, r.dir];
}
/**
* Get the CCS directory as a user-facing display path.
* Keeps the default path concise while preserving explicit overrides.
*/
export function getCcsDirDisplay(): string {
const [source, dir] = getCcsDirSource();
return source === 'default' ? '~/.ccs' : dir;
}
/**
* Cloud sync folder patterns for security warning.
*/
@@ -8,7 +8,15 @@
*/
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
import { resolveCliproxyBridgeProfile } from '../../api/services/cliproxy-profile-bridge';
import { getEffectiveApiKey } from '../../cliproxy/auth-token-manager';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
import {
buildProxyUrl,
getProxyTarget,
type ProxyTarget,
} from '../../cliproxy/proxy-target-resolver';
import { getPromptsDir } from '../image-analysis/hook-installer';
import {
resolveImageAnalysisStatus,
type ImageAnalysisResolutionContext,
@@ -23,6 +31,61 @@ function serializeProviderModels(providerModels: Record<string, string>): string
.join(',');
}
export interface ImageAnalysisRuntimeOverrides {
backendId?: string | null;
model?: string | null;
runtimePath?: string | null;
baseUrl?: string | null;
apiKey?: string | null;
allowSelfSigned?: boolean | null;
}
export interface ImageAnalysisRuntimeConnection {
baseUrl: string;
apiKey: string;
allowSelfSigned: boolean;
proxyTarget: ProxyTarget;
}
export interface ResolveImageAnalysisRuntimeConnectionOptions {
proxyTarget?: ProxyTarget;
tunnelPort?: number | null;
}
function stripTrailingSlash(value: string): string {
return value.replace(/\/+$/, '');
}
export function resolveImageAnalysisRuntimeConnection(
options: ResolveImageAnalysisRuntimeConnectionOptions = {}
): ImageAnalysisRuntimeConnection {
const proxyTarget = options.proxyTarget ?? getProxyTarget();
const apiKey = proxyTarget.authToken?.trim() || getEffectiveApiKey();
if (proxyTarget.isRemote && options.tunnelPort && options.tunnelPort > 0) {
return {
baseUrl: `http://127.0.0.1:${options.tunnelPort}`,
apiKey,
allowSelfSigned: false,
proxyTarget: {
host: '127.0.0.1',
port: options.tunnelPort,
protocol: 'http',
isRemote: false,
},
};
}
return {
baseUrl: stripTrailingSlash(buildProxyUrl(proxyTarget, '')),
apiKey,
allowSelfSigned: Boolean(
proxyTarget.isRemote && proxyTarget.protocol === 'https' && proxyTarget.allowSelfSigned
),
proxyTarget,
};
}
/**
* Get image analysis hook environment variables.
* These env vars control the hook's behavior via Claude Code hook system.
@@ -45,12 +108,70 @@ export function getImageAnalysisHookEnv(
? resolveImageAnalysisStatus(context, config)
: resolveImageAnalysisStatus({ profileName: '' }, config);
const skipImageAnalysis = !status.supported;
const runtimeApiKey =
typeof context === 'object' && context.cliproxyBridge
? resolveCliproxyBridgeProfile(context.cliproxyBridge.provider).apiKey
: '';
return {
CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0',
CCS_IMAGE_ANALYSIS_TIMEOUT: String(Number(config.timeout) || 60),
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: serializeProviderModels(config.provider_models),
CCS_CURRENT_PROVIDER: status.backendId || '',
CCS_IMAGE_ANALYSIS_BACKEND_ID: status.backendId || '',
CCS_IMAGE_ANALYSIS_MODEL: status.model || '',
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: status.runtimePath || '',
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL:
typeof context === 'object' ? context.cliproxyBridge?.currentBaseUrl || '' : '',
...(runtimeApiKey ? { CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: runtimeApiKey } : {}),
CCS_IMAGE_ANALYSIS_PROMPTS_DIR: getPromptsDir(),
CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0',
};
}
/**
* Overlay execution-specific runtime values onto the baseline image-analysis env.
* Launch paths use this to pin analysis to the exact provider route and auth
* token selected for the current session rather than any stale saved values.
*/
export function applyImageAnalysisRuntimeOverrides(
env: Record<string, string>,
overrides: ImageAnalysisRuntimeOverrides
): Record<string, string> {
const nextEnv = { ...env };
const backendId = overrides.backendId?.trim();
if (backendId) {
nextEnv.CCS_CURRENT_PROVIDER = backendId;
nextEnv.CCS_IMAGE_ANALYSIS_BACKEND_ID = backendId;
}
const model = overrides.model?.trim();
if (model) {
nextEnv.CCS_IMAGE_ANALYSIS_MODEL = model;
}
const runtimePath = overrides.runtimePath?.trim();
if (runtimePath) {
nextEnv.CCS_IMAGE_ANALYSIS_RUNTIME_PATH = runtimePath;
}
if (overrides.baseUrl !== undefined) {
nextEnv.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL = overrides.baseUrl?.trim() || '';
}
if (overrides.apiKey !== undefined) {
const apiKey = overrides.apiKey?.trim();
if (apiKey) {
nextEnv.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY = apiKey;
} else {
delete nextEnv.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY;
}
}
if (overrides.allowSelfSigned !== undefined) {
nextEnv.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED = overrides.allowSelfSigned ? '1' : '0';
}
return nextEnv;
}
@@ -396,6 +396,16 @@ function resolveBackend(
};
}
if (profileType === 'cursor' || profileName === 'cursor') {
return {
backendId: null,
backendDisplayName: null,
resolutionSource: 'unresolved',
reason:
'Cursor image analysis does not inherit the global fallback backend. Set image_analysis.profile_backends.cursor to an explicit provider to enable transformer-backed image analysis.',
};
}
if (profileType === 'copilot' || profileName === 'copilot') {
const backendId = normalizeImageAnalysisBackendId('ghcp', Object.keys(config.provider_models));
return {
@@ -566,13 +576,13 @@ export function resolveImageAnalysisStatus(
? 'CLIProxy runtime readiness has not been verified yet.'
: null,
effectiveRuntimeMode:
config.enabled && resolution.backendId && model && status !== 'hook-missing'
? 'cliproxy-image-analysis'
: 'native-read',
config.enabled && resolution.backendId && model ? 'cliproxy-image-analysis' : 'native-read',
effectiveRuntimeReason:
status === 'hook-missing' || !config.enabled || !resolution.backendId || !model
!config.enabled || !resolution.backendId || !model
? reason
: null,
: status === 'attention' || status === 'hook-missing'
? reason
: null,
profileModel: nativeSupport.profileModel,
nativeReadPreference: nativeSupport.nativeReadPreference,
nativeImageCapable: nativeSupport.nativeImageCapable,
@@ -1,4 +1,9 @@
import { getAuthStatus, initializeAccounts, type AuthStatus } from '../../cliproxy/auth-handler';
import {
checkRemoteProxy,
type RemoteProxyClientConfig,
type RemoteProxyStatus,
} from '../../cliproxy/remote-proxy-client';
import { fetchRemoteAuthStatus, type RemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
import { getProxyTarget, type ProxyTarget } from '../../cliproxy/proxy-target-resolver';
import { getProviderDisplayName, isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
@@ -15,6 +20,7 @@ import {
} from './image-analysis-backend-resolver';
interface ImageAnalysisRuntimeStatusDeps {
checkRemoteProxy: (config: RemoteProxyClientConfig) => Promise<RemoteProxyStatus>;
fetchRemoteAuthStatus: (target: ProxyTarget) => Promise<RemoteAuthStatus[]>;
getAuthStatus: (provider: CLIProxyProvider) => AuthStatus;
getProxyTarget: () => ProxyTarget;
@@ -23,6 +29,7 @@ interface ImageAnalysisRuntimeStatusDeps {
}
const defaultDeps: ImageAnalysisRuntimeStatusDeps = {
checkRemoteProxy,
fetchRemoteAuthStatus,
getAuthStatus,
getProxyTarget,
@@ -91,16 +98,25 @@ async function resolveProxyReadiness(
}
const target = deps.getProxyTarget();
const reachable = await deps.isCliproxyRunning();
if (target.isRemote) {
const remoteStatus = await deps.checkRemoteProxy({
host: target.host,
port: target.port,
protocol: target.protocol,
authToken: target.authToken,
allowSelfSigned: target.allowSelfSigned,
});
return {
proxyReadiness: reachable ? 'remote' : 'unavailable',
proxyReason: reachable
proxyReadiness: remoteStatus.reachable ? 'remote' : 'unavailable',
proxyReason: remoteStatus.reachable
? `Remote CLIProxy target ${target.host}:${target.port} is reachable.`
: `Remote CLIProxy target ${target.host}:${target.port} is unreachable.`,
: remoteStatus.error ||
`Remote CLIProxy target ${target.host}:${target.port} is unreachable.`,
};
}
const reachable = await deps.isCliproxyRunning();
return {
proxyReadiness: reachable ? 'ready' : 'stopped',
proxyReason: reachable
@@ -119,13 +135,6 @@ function resolveEffectiveRuntime(
};
}
if (status.status === 'hook-missing') {
return {
effectiveRuntimeMode: 'native-read',
effectiveRuntimeReason: status.reason,
};
}
if (status.authReadiness === 'missing' || status.authReadiness === 'unknown') {
return {
effectiveRuntimeMode: 'native-read',
@@ -142,7 +151,8 @@ function resolveEffectiveRuntime(
return {
effectiveRuntimeMode: 'cliproxy-image-analysis',
effectiveRuntimeReason: status.status === 'attention' ? status.reason : null,
effectiveRuntimeReason:
status.status === 'attention' || status.status === 'hook-missing' ? status.reason : null,
};
}
@@ -14,6 +14,7 @@ import { getImageAnalyzerHookPath } from './image-analyzer-hook-configuration';
import { getCcsHooksDir } from '../config-manager';
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
import { removeMigrationMarker } from './image-analyzer-profile-hook-injector';
import { installImageAnalysisPrompts } from '../image-analysis/hook-installer';
// Re-export from hook-configuration for backward compatibility
export {
@@ -23,12 +24,65 @@ export {
// Hook file name
const IMAGE_ANALYZER_HOOK = 'image-analyzer-transformer.cjs';
const IMAGE_ANALYSIS_RUNTIME = 'image-analysis-runtime.cjs';
function getImageAnalysisRuntimeHookPath(): string {
return path.join(getCcsHooksDir(), IMAGE_ANALYSIS_RUNTIME);
}
function getHookArtifacts(): Array<{ fileName: string; destinationPath: string }> {
return [
{ fileName: IMAGE_ANALYZER_HOOK, destinationPath: getImageAnalyzerHookPath() },
{
fileName: IMAGE_ANALYSIS_RUNTIME,
destinationPath: getImageAnalysisRuntimeHookPath(),
},
];
}
function resolveHookSourceBasePath(
artifacts: Array<{ fileName: string; destinationPath: string }>
): string | null {
const possibleBasePaths = [
path.join(__dirname, '..', '..', '..', 'lib', 'hooks'),
path.join(__dirname, '..', '..', 'lib', 'hooks'),
path.join(__dirname, '..', 'lib', 'hooks'),
];
for (const basePath of possibleBasePaths) {
if (artifacts.every(({ fileName }) => fs.existsSync(path.join(basePath, fileName)))) {
return basePath;
}
}
return null;
}
function artifactsMatch(sourcePath: string, destinationPath: string): boolean {
try {
return fs.readFileSync(sourcePath).equals(fs.readFileSync(destinationPath));
} catch {
return false;
}
}
/**
* Check if image analyzer hook is installed
*/
export function hasImageAnalyzerHook(): boolean {
return fs.existsSync(getImageAnalyzerHookPath());
const artifacts = getHookArtifacts();
if (!artifacts.every(({ destinationPath }) => fs.existsSync(destinationPath))) {
return false;
}
const sourceBasePath = resolveHookSourceBasePath(artifacts);
if (!sourceBasePath) {
return true;
}
return artifacts.every(({ fileName, destinationPath }) =>
artifactsMatch(path.join(sourceBasePath, fileName), destinationPath)
);
}
/**
@@ -56,38 +110,25 @@ export function installImageAnalyzerHook(): boolean {
fs.mkdirSync(hooksDir, { recursive: true, mode: 0o700 });
}
const hookPath = getImageAnalyzerHookPath();
const artifacts = getHookArtifacts();
const sourceBasePath = resolveHookSourceBasePath(artifacts);
// Find the bundled hook script
// In npm package: node_modules/ccs/lib/hooks/
// In development: lib/hooks/
const possiblePaths = [
path.join(__dirname, '..', '..', '..', 'lib', 'hooks', IMAGE_ANALYZER_HOOK),
path.join(__dirname, '..', '..', 'lib', 'hooks', IMAGE_ANALYZER_HOOK),
path.join(__dirname, '..', 'lib', 'hooks', IMAGE_ANALYZER_HOOK),
];
let sourcePath: string | null = null;
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
sourcePath = p;
break;
}
}
if (!sourcePath) {
if (!sourceBasePath) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Image analyzer hook source not found: ${IMAGE_ANALYZER_HOOK}`));
}
return false;
}
// Copy hook to ~/.ccs/hooks/
fs.copyFileSync(sourcePath, hookPath);
fs.chmodSync(hookPath, 0o755);
for (const { fileName, destinationPath } of artifacts) {
fs.copyFileSync(path.join(sourceBasePath, fileName), destinationPath);
fs.chmodSync(destinationPath, 0o755);
}
installImageAnalysisPrompts();
if (process.env.CCS_DEBUG) {
console.error(info(`Installed image analyzer hook: ${hookPath}`));
console.error(info(`Installed image analyzer hook runtime: ${hooksDir}`));
}
// Note: Hook registration is handled by ensureProfileHooks() in image-analyzer-profile-injector.ts
@@ -113,12 +154,14 @@ export function installImageAnalyzerHook(): boolean {
*/
export function uninstallImageAnalyzerHook(): boolean {
try {
const hookPath = getImageAnalyzerHookPath();
const artifactPaths = [getImageAnalyzerHookPath(), getImageAnalysisRuntimeHookPath()];
if (fs.existsSync(hookPath)) {
fs.unlinkSync(hookPath);
if (process.env.CCS_DEBUG) {
console.error(info(`Uninstalled image analyzer hook: ${hookPath}`));
for (const artifactPath of artifactPaths) {
if (fs.existsSync(artifactPath)) {
fs.unlinkSync(artifactPath);
if (process.env.CCS_DEBUG) {
console.error(info(`Uninstalled image analyzer artifact: ${artifactPath}`));
}
}
}
@@ -138,6 +138,10 @@ export function ensureProfileHooks(input: string | ImageAnalysisResolutionContex
return false;
}
if (context.sharedHookInstalled === false) {
return false;
}
// One-time migration marker
migrateGlobalHook();
+17 -1
View File
@@ -6,7 +6,19 @@
* @module utils/hooks
*/
export { getImageAnalysisHookEnv } from './get-image-analysis-hook-env';
import {
hasImageAnalyzerHook as hasInstalledImageAnalyzerHook,
installImageAnalyzerHook as installSharedImageAnalyzerHook,
} from './image-analyzer-hook-installer';
export {
getImageAnalysisHookEnv,
applyImageAnalysisRuntimeOverrides,
resolveImageAnalysisRuntimeConnection,
type ImageAnalysisRuntimeOverrides,
type ImageAnalysisRuntimeConnection,
type ResolveImageAnalysisRuntimeConnectionOptions,
} from './get-image-analysis-hook-env';
export {
canonicalizeImageAnalysisConfig,
resolveImageAnalysisStatus,
@@ -26,3 +38,7 @@ export {
uninstallImageAnalyzerHook,
} from './image-analyzer-hook-installer';
export { ensureProfileHooks as ensureImageAnalyzerProfileHooks } from './image-analyzer-profile-hook-injector';
export function prepareImageAnalysisFallbackHook(): boolean {
return hasInstalledImageAnalyzerHook() || installSharedImageAnalyzerHook();
}
@@ -0,0 +1,74 @@
/**
* Claude launch argument helpers for first-class Image Analysis.
*/
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
const IMAGE_ANALYSIS_STEERING_PROMPT =
'For local image or PDF files, prefer the CCS MCP tool ImageAnalysis instead of Read. Use Read for text, code, and other plain files. If the user asks a specific question about the visual, pass that question as the focus field when useful. If ImageAnalysis is unavailable or fails, you may fall back to Read.';
function splitArgsAtTerminator(args: string[]): { optionArgs: string[]; trailingArgs: string[] } {
const terminatorIndex = args.indexOf('--');
if (terminatorIndex === -1) {
return { optionArgs: args, trailingArgs: [] };
}
return {
optionArgs: args.slice(0, terminatorIndex),
trailingArgs: args.slice(terminatorIndex),
};
}
function getImmediateFlagValue(args: string[], index: number): string | null {
const value = args[index + 1];
if (value === undefined || value === '--' || value.startsWith('--')) {
return null;
}
return value;
}
function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) {
const value = getImmediateFlagValue(args, index);
if (value === expectedValue) {
return true;
}
continue;
}
if (arg === `${flag}=${expectedValue}`) {
return true;
}
if (arg.startsWith(`${flag}=`) && arg.slice(flag.length + 1) === expectedValue) {
return true;
}
}
return false;
}
function ensureImageAnalysisSteeringPrompt(args: string[]): string[] {
const { optionArgs, trailingArgs } = splitArgsAtTerminator(args);
if (hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, IMAGE_ANALYSIS_STEERING_PROMPT)) {
return args;
}
return [
...optionArgs,
APPEND_SYSTEM_PROMPT_FLAG,
IMAGE_ANALYSIS_STEERING_PROMPT,
...trailingArgs,
];
}
export function appendThirdPartyImageAnalysisToolArgs(args: string[]): string[] {
return ensureImageAnalysisSteeringPrompt(args);
}
export function getImageAnalysisSteeringPrompt(): string {
return IMAGE_ANALYSIS_STEERING_PROMPT;
}
+23
View File
@@ -5,3 +5,26 @@
*/
export { getPromptsDir, installImageAnalysisPrompts } from './hook-installer';
export {
getImageAnalysisMcpServerName,
getImageAnalysisMcpServerPath,
getImageAnalysisMcpRuntimePath,
hasImageAnalysisMcpServerInstalled,
hasImageAnalysisMcpConfig,
hasImageAnalysisMcpReady,
installImageAnalysisMcpServer,
ensureImageAnalysisMcpConfig,
ensureImageAnalysisMcp,
uninstallImageAnalysisMcpServer,
removeImageAnalysisMcpConfig,
uninstallImageAnalysisMcp,
syncImageAnalysisMcpToConfigDir,
ensureImageAnalysisMcpOrThrow,
} from './mcp-installer';
export {
appendThirdPartyImageAnalysisToolArgs,
getImageAnalysisSteeringPrompt,
} from './claude-tool-args';
export const IMAGE_ANALYSIS_PROMPT_TEMPLATES = ['default', 'screenshot', 'document'] as const;
export type ImageAnalysisPromptTemplate = (typeof IMAGE_ANALYSIS_PROMPT_TEMPLATES)[number];
+500
View File
@@ -0,0 +1,500 @@
/**
* Image Analysis MCP installer and ~/.claude.json provisioning.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as lockfile from 'proper-lockfile';
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
import { getCcsDir } from '../config-manager';
import { getClaudeUserConfigPath } from '../claude-config-path';
import { info, warn } from '../ui';
import { InstanceManager } from '../../management/instance-manager';
import { installImageAnalysisPrompts } from './hook-installer';
const IMAGE_ANALYSIS_MCP_SERVER = 'ccs-image-analysis-server.cjs';
const IMAGE_ANALYSIS_MCP_RUNTIME = 'image-analysis-runtime.cjs';
const IMAGE_ANALYSIS_MCP_SERVER_NAME = 'ccs-image-analysis';
interface ClaudeUserConfig {
mcpServers?: Record<string, unknown>;
[key: string]: unknown;
}
interface ManagedImageAnalysisMcpConfig {
type: 'stdio';
command: 'node';
args: [string];
env: Record<string, string>;
}
function getCcsMcpDir(): string {
return path.join(getCcsDir(), 'mcp');
}
export function getImageAnalysisMcpServerName(): string {
return IMAGE_ANALYSIS_MCP_SERVER_NAME;
}
export function getImageAnalysisMcpServerPath(): string {
return path.join(getCcsMcpDir(), IMAGE_ANALYSIS_MCP_SERVER);
}
export function getImageAnalysisMcpRuntimePath(): string {
return path.join(getCcsMcpDir(), IMAGE_ANALYSIS_MCP_RUNTIME);
}
function hasMatchingContents(sourcePath: string, destinationPath: string): boolean {
if (!fs.existsSync(destinationPath)) {
return false;
}
const source = fs.readFileSync(sourcePath);
try {
const destination = fs.readFileSync(destinationPath);
return source.equals(destination);
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(
warn(`Existing Image Analysis MCP server is unreadable: ${(error as Error).message}`)
);
}
return false;
}
}
function getTempPath(targetPath: string): string {
const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${targetPath}.${suffix}.tmp`;
}
function resolveBundledArtifactSourcePath(fileName: string): string | null {
const possiblePaths = [
path.join(__dirname, '..', '..', '..', 'lib', 'mcp', fileName),
path.join(__dirname, '..', '..', 'lib', 'mcp', fileName),
path.join(__dirname, '..', 'lib', 'mcp', fileName),
path.join(__dirname, '..', '..', '..', 'lib', 'hooks', fileName),
path.join(__dirname, '..', '..', 'lib', 'hooks', fileName),
path.join(__dirname, '..', 'lib', 'hooks', fileName),
];
for (const candidate of possiblePaths) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
function readClaudeUserConfig(configPath: string): ClaudeUserConfig | null {
if (!fs.existsSync(configPath)) {
return {};
}
try {
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return null;
}
return parsed as ClaudeUserConfig;
} catch {
return null;
}
}
function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): boolean {
const tempPath = getTempPath(configPath);
const fileMode = fs.existsSync(configPath) ? fs.statSync(configPath).mode & 0o777 : 0o600;
try {
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
fs.chmodSync(tempPath, fileMode);
fs.renameSync(tempPath, configPath);
return true;
} finally {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
function withClaudeUserConfigLock<T>(configPath: string, callback: () => T): T {
const configDir = path.dirname(configPath);
const lockTarget = path.join(configDir, `${path.basename(configPath)}.ccs-lock`);
let release: (() => void) | undefined;
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
}
if (!fs.existsSync(lockTarget)) {
fs.writeFileSync(lockTarget, '', { encoding: 'utf8', mode: 0o600 });
}
try {
release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void;
return callback();
} finally {
if (release) {
try {
release();
} catch {
// Best-effort release.
}
}
}
}
function isLockUnavailableError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === 'ELOCKED' || code === 'ENOTACQUIRED';
}
export function hasImageAnalysisMcpServerInstalled(): boolean {
return (
fs.existsSync(getImageAnalysisMcpServerPath()) &&
fs.existsSync(getImageAnalysisMcpRuntimePath())
);
}
export function hasImageAnalysisMcpConfig(configPath = getClaudeUserConfigPath()): boolean {
const config = readClaudeUserConfig(configPath);
if (config === null) {
return false;
}
const existingServers =
config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)
? (config.mcpServers as Record<string, unknown>)
: {};
const currentConfig = existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME];
return (
typeof currentConfig === 'object' &&
currentConfig !== null &&
JSON.stringify(currentConfig) ===
JSON.stringify({
type: 'stdio',
command: 'node',
args: [getImageAnalysisMcpServerPath()],
env: {},
})
);
}
export function hasImageAnalysisMcpReady(configPath = getClaudeUserConfigPath()): boolean {
return hasImageAnalysisMcpServerInstalled() && hasImageAnalysisMcpConfig(configPath);
}
function removeManagedServerConfig(configPath: string): boolean {
if (!fs.existsSync(configPath)) {
return false;
}
try {
return withClaudeUserConfigLock(configPath, () => {
const config = readClaudeUserConfig(configPath);
if (config === null) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Malformed Claude config prevents MCP cleanup: ${configPath}`));
}
return false;
}
const existingServers =
config.mcpServers &&
typeof config.mcpServers === 'object' &&
!Array.isArray(config.mcpServers)
? { ...(config.mcpServers as Record<string, unknown>) }
: {};
if (!(IMAGE_ANALYSIS_MCP_SERVER_NAME in existingServers)) {
return false;
}
delete existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME];
const nextConfig: ClaudeUserConfig = { ...config };
if (Object.keys(existingServers).length === 0) {
delete nextConfig.mcpServers;
} else {
nextConfig.mcpServers = existingServers;
}
try {
writeClaudeUserConfig(configPath, nextConfig);
if (process.env.CCS_DEBUG) {
console.error(info(`Removed Image Analysis MCP config from ${configPath}`));
}
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(
warn(
`Failed to remove Image Analysis MCP config from ${configPath}: ${(error as Error).message}`
)
);
}
return false;
}
});
} catch (error) {
if (isLockUnavailableError(error)) {
if (process.env.CCS_DEBUG) {
console.error(
warn(
`Image Analysis MCP cleanup skipped because ${configPath} is locked by another process`
)
);
}
return false;
}
throw error;
}
}
export function installImageAnalysisMcpServer(): boolean {
const config = getImageAnalysisConfig();
if (!config.enabled) {
return false;
}
const artifacts = [
{
fileName: IMAGE_ANALYSIS_MCP_SERVER,
sourcePath: resolveBundledArtifactSourcePath(IMAGE_ANALYSIS_MCP_SERVER),
destinationPath: getImageAnalysisMcpServerPath(),
},
{
fileName: IMAGE_ANALYSIS_MCP_RUNTIME,
sourcePath: resolveBundledArtifactSourcePath(IMAGE_ANALYSIS_MCP_RUNTIME),
destinationPath: getImageAnalysisMcpRuntimePath(),
},
];
const missingArtifact = artifacts.find((artifact) => !artifact.sourcePath);
if (missingArtifact) {
if (process.env.CCS_DEBUG) {
console.error(
warn(`Image Analysis MCP runtime source not found: ${missingArtifact.fileName}`)
);
}
return false;
}
const mcpDir = getCcsMcpDir();
if (!fs.existsSync(mcpDir)) {
fs.mkdirSync(mcpDir, { recursive: true, mode: 0o700 });
}
try {
for (const artifact of artifacts) {
const sourcePath = artifact.sourcePath;
if (!sourcePath) {
continue;
}
if (hasMatchingContents(sourcePath, artifact.destinationPath)) {
continue;
}
const tempPath = getTempPath(artifact.destinationPath);
try {
fs.copyFileSync(sourcePath, tempPath);
fs.chmodSync(tempPath, 0o755);
try {
fs.renameSync(tempPath, artifact.destinationPath);
} catch (renameError) {
const errorCode = (renameError as NodeJS.ErrnoException).code;
if (errorCode !== 'EEXIST' && errorCode !== 'EPERM') {
throw renameError;
}
if (!hasMatchingContents(sourcePath, artifact.destinationPath)) {
fs.copyFileSync(tempPath, artifact.destinationPath);
fs.chmodSync(artifact.destinationPath, 0o755);
}
}
} finally {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
installImageAnalysisPrompts();
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(
warn(`Failed to install Image Analysis MCP server: ${(error as Error).message}`)
);
}
return false;
}
}
export function ensureImageAnalysisMcpConfig(): boolean {
const imageConfig = getImageAnalysisConfig();
if (!imageConfig.enabled) {
return false;
}
const claudeUserConfigPath = getClaudeUserConfigPath();
const claudeUserConfigDir = path.dirname(claudeUserConfigPath);
if (!fs.existsSync(claudeUserConfigDir)) {
fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 });
}
const desiredServerConfig: ManagedImageAnalysisMcpConfig = {
type: 'stdio',
command: 'node',
args: [getImageAnalysisMcpServerPath()],
env: {},
};
try {
return withClaudeUserConfigLock(claudeUserConfigPath, () => {
const config = readClaudeUserConfig(claudeUserConfigPath);
if (config === null) {
if (process.env.CCS_DEBUG) {
console.error(warn('Malformed ~/.claude.json prevents Image Analysis MCP provisioning'));
}
return false;
}
const existingServers =
config.mcpServers &&
typeof config.mcpServers === 'object' &&
!Array.isArray(config.mcpServers)
? (config.mcpServers as Record<string, unknown>)
: {};
const currentConfig = existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME];
if (
typeof currentConfig === 'object' &&
currentConfig !== null &&
JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig)
) {
return true;
}
const nextConfig: ClaudeUserConfig = {
...config,
mcpServers: {
...existingServers,
[IMAGE_ANALYSIS_MCP_SERVER_NAME]: desiredServerConfig,
},
};
try {
writeClaudeUserConfig(claudeUserConfigPath, nextConfig);
if (process.env.CCS_DEBUG) {
console.error(info(`Ensured Image Analysis MCP config in ${claudeUserConfigPath}`));
}
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to update ~/.claude.json: ${(error as Error).message}`));
}
return false;
}
});
} catch (error) {
if (isLockUnavailableError(error)) {
if (process.env.CCS_DEBUG) {
console.error(
warn(
`Image Analysis MCP provisioning skipped because ${claudeUserConfigPath} is locked by another process`
)
);
}
return false;
}
throw error;
}
}
export function ensureImageAnalysisMcp(): boolean {
const imageConfig = getImageAnalysisConfig();
if (!imageConfig.enabled) {
return false;
}
const installed = installImageAnalysisMcpServer();
const configured = installed && ensureImageAnalysisMcpConfig();
return installed && configured;
}
export function syncImageAnalysisMcpToConfigDir(claudeConfigDir: string | undefined): boolean {
if (!claudeConfigDir) {
return false;
}
return new InstanceManager().syncMcpServers(claudeConfigDir);
}
export function uninstallImageAnalysisMcpServer(): boolean {
const artifactPaths = [getImageAnalysisMcpServerPath(), getImageAnalysisMcpRuntimePath()];
if (!artifactPaths.some((artifactPath) => fs.existsSync(artifactPath))) {
return false;
}
try {
let removed = false;
for (const artifactPath of artifactPaths) {
if (!fs.existsSync(artifactPath)) {
continue;
}
fs.unlinkSync(artifactPath);
removed = true;
}
return removed;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(
warn(`Failed to remove Image Analysis MCP server: ${(error as Error).message}`)
);
}
return false;
}
}
export function removeImageAnalysisMcpConfig(): boolean {
let removed = removeManagedServerConfig(getClaudeUserConfigPath());
const instanceManager = new InstanceManager();
for (const instanceName of instanceManager.listInstances()) {
const instancePath = instanceManager.getInstancePath(instanceName);
const instanceClaudeConfigPath = path.join(instancePath, '.claude.json');
removed = removeManagedServerConfig(instanceClaudeConfigPath) || removed;
}
return removed;
}
export function uninstallImageAnalysisMcp(): boolean {
const removedConfig = removeImageAnalysisMcpConfig();
const removedServer = uninstallImageAnalysisMcpServer();
return removedConfig || removedServer;
}
export function ensureImageAnalysisMcpOrThrow(): boolean {
const imageConfig = getImageAnalysisConfig();
if (!imageConfig.enabled) {
return false;
}
const ready = ensureImageAnalysisMcp();
if (!ready) {
console.error(
warn(
'Image Analysis is enabled, but CCS could not prepare the local ImageAnalysis tool. This session will fall back to native Read.'
)
);
}
return ready;
}
+14 -2
View File
@@ -32,6 +32,7 @@ import {
type MergedAccountEntry,
} from './account-route-helpers';
import type { AccountConfig } from '../../config/unified-config-types';
import { resolveConfiguredPlainCcsResumeLane } from '../../auth/resume-lane-diagnostics';
const router = Router();
@@ -59,7 +60,7 @@ function hasAuthAccount(name: string): boolean {
/**
* GET /api/accounts - List accounts from both profiles.json and config.yaml
*/
router.get('/', (_req: Request, res: Response): void => {
router.get('/', async (_req: Request, res: Response): Promise<void> => {
try {
const registry = createProfileRegistry();
@@ -147,8 +148,19 @@ router.get('/', (_req: Request, res: Response): void => {
// Get default from unified config first, fallback to legacy
const defaultProfile = registry.getDefaultUnified() ?? registry.getDefaultProfile() ?? null;
const plainCcsLane = await resolveConfiguredPlainCcsResumeLane();
res.json({ accounts, default: defaultProfile });
res.json({
accounts,
default: defaultProfile,
plain_ccs_lane: {
kind: plainCcsLane.kind,
label: plainCcsLane.label,
account_name: plainCcsLane.accountName ?? null,
profile_name: plainCcsLane.profileName ?? null,
project_count: plainCcsLane.projectCount,
},
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
+119 -13
View File
@@ -62,6 +62,7 @@ import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middlewar
const router = Router();
const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000;
const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
type ProviderTokenSnapshot = {
file: string;
mtimeMs: number;
@@ -74,6 +75,7 @@ const pendingManualAuthState = new Map<
nickname?: string;
expectedAccountId?: string;
createdAt: number;
upstreamCompletedAt?: number;
knownTokenFiles: ProviderTokenSnapshot[];
}
>();
@@ -97,7 +99,12 @@ router.use((req: Request, res: Response, next) => {
function pruneExpiredManualAuthState(now = Date.now()): void {
for (const [state, pending] of pendingManualAuthState.entries()) {
if (now - pending.createdAt > MANUAL_AUTH_STATE_TTL_MS) {
const authExpired = now - pending.createdAt > MANUAL_AUTH_STATE_TTL_MS;
const withinLocalTokenGrace =
pending.upstreamCompletedAt !== undefined &&
now - pending.upstreamCompletedAt < POLLED_AUTH_LOCAL_TOKEN_GRACE_MS;
if (authExpired && !withinLocalTokenGrace) {
pendingManualAuthState.delete(state);
}
}
@@ -122,6 +129,7 @@ function getManualAuthState(state: string | undefined): {
nickname?: string;
expectedAccountId?: string;
createdAt: number;
upstreamCompletedAt?: number;
knownTokenFiles: ProviderTokenSnapshot[];
} | null {
if (!state) {
@@ -138,10 +146,27 @@ function getManualAuthState(state: string | undefined): {
nickname: pending.nickname,
expectedAccountId: pending.expectedAccountId,
createdAt: pending.createdAt,
upstreamCompletedAt: pending.upstreamCompletedAt,
knownTokenFiles: pending.knownTokenFiles,
};
}
function markManualAuthUpstreamCompleted(state: string, now = Date.now()): number | null {
pruneExpiredManualAuthState(now);
const pending = pendingManualAuthState.get(state);
if (!pending) {
return null;
}
if (pending.upstreamCompletedAt !== undefined) {
return pending.upstreamCompletedAt;
}
pending.upstreamCompletedAt = now;
pendingManualAuthState.set(state, pending);
return now;
}
function listProviderTokenSnapshots(provider: CLIProxyProvider): ProviderTokenSnapshot[] {
const tokenDir = getProviderTokenDir(provider);
if (!fs.existsSync(tokenDir)) {
@@ -179,7 +204,7 @@ function listProviderTokenSnapshots(provider: CLIProxyProvider): ProviderTokenSn
function findNewTokenSnapshotForPendingAuth(
provider: CLIProxyProvider,
pending: { knownTokenFiles: ProviderTokenSnapshot[] }
pending: { expectedAccountId?: string; knownTokenFiles: ProviderTokenSnapshot[] }
): ProviderTokenSnapshot | null {
const knownTokenMtimes = new Map(
pending.knownTokenFiles.map((snapshot) => [snapshot.file, snapshot.mtimeMs])
@@ -188,11 +213,32 @@ function findNewTokenSnapshotForPendingAuth(
return (
listProviderTokenSnapshots(provider).find((snapshot) => {
const knownMtime = knownTokenMtimes.get(snapshot.file);
return knownMtime === undefined || snapshot.mtimeMs > knownMtime + 1;
if (knownMtime === undefined) {
return true;
}
if (!pending.expectedAccountId) {
return false;
}
return snapshot.mtimeMs > knownMtime + 1;
}) || null
);
}
function shouldKeepWaitingForLocalToken(
state: string,
pending: { upstreamCompletedAt?: number },
now = Date.now()
): boolean {
const upstreamCompletedAt =
pending.upstreamCompletedAt ?? markManualAuthUpstreamCompleted(state, now);
return (
upstreamCompletedAt !== null && now - upstreamCompletedAt < POLLED_AUTH_LOCAL_TOKEN_GRACE_MS
);
}
function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } {
if (raw === undefined || raw === null) {
return { method: normalizeKiroAuthMethod(), invalid: false };
@@ -916,8 +962,15 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
return;
}
const now = Date.now();
const tokenSnapshot = findNewTokenSnapshotForPendingAuth(localProvider, pendingAuth);
if (!tokenSnapshot) {
if (shouldKeepWaitingForLocalToken(state, pendingAuth, now)) {
res.json({ status: 'wait' });
return;
}
pendingManualAuthState.delete(state);
res.status(409).json({
status: 'error',
error:
@@ -935,6 +988,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
);
if (!account) {
pendingManualAuthState.delete(state);
res.status(409).json({
status: 'error',
error: getManualCallbackRegistrationError(localProvider),
@@ -1048,20 +1102,72 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
return;
}
const account = registerAccountFromToken(
provider as CLIProxyProvider,
getProviderTokenDir(provider as CLIProxyProvider),
pendingAuth?.nickname,
false,
pendingAuth?.expectedAccountId
);
if (parsed.state) {
pendingManualAuthState.delete(parsed.state);
const localProvider = provider as CLIProxyProvider;
const now = Date.now();
if (pendingAuth) {
const tokenSnapshot = findNewTokenSnapshotForPendingAuth(localProvider, pendingAuth);
if (!tokenSnapshot) {
if (parsed.state && shouldKeepWaitingForLocalToken(parsed.state, pendingAuth, now)) {
res.json({ status: 'wait' });
return;
}
if (parsed.state) {
pendingManualAuthState.delete(parsed.state);
}
res.status(409).json({
error: getManualCallbackRegistrationError(localProvider),
});
return;
}
const account = registerAccountFromToken(
localProvider,
getProviderTokenDir(localProvider),
pendingAuth.nickname,
false,
tokenSnapshot.file
);
if (!account) {
if (parsed.state) {
pendingManualAuthState.delete(parsed.state);
}
res.status(409).json({
error: getManualCallbackRegistrationError(localProvider),
});
return;
}
if (parsed.state) {
pendingManualAuthState.delete(parsed.state);
}
res.json({
success: true,
account: {
id: account.id,
email: account.email,
nickname: account.nickname,
provider: account.provider,
isDefault: account.isDefault,
},
});
return;
}
const account = registerAccountFromToken(
localProvider,
getProviderTokenDir(localProvider),
undefined,
false,
undefined
);
if (!account) {
res.status(409).json({
error: getManualCallbackRegistrationError(provider as CLIProxyProvider),
error: getManualCallbackRegistrationError(localProvider),
});
return;
}
+43 -4
View File
@@ -16,9 +16,15 @@ import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer'
import {
normalizeImageAnalysisBackendId,
resolveImageAnalysisRuntimeStatus,
prepareImageAnalysisFallbackHook,
} from '../../utils/hooks';
import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer';
import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector';
import { InstanceManager } from '../../management/instance-manager';
import {
ensureImageAnalysisMcpOrThrow,
hasImageAnalysisMcpReady,
} from '../../utils/image-analysis';
const router = Router();
const IMAGE_ANALYSIS_LOCAL_ACCESS_ERROR =
@@ -78,17 +84,25 @@ function resolveTarget(target: unknown): DashboardTarget {
function resolveCurrentTargetMode(
target: DashboardTarget,
status: Awaited<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>
status: Awaited<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>,
managedToolReady: boolean
): CurrentTargetMode {
if (!status.enabled) return 'disabled';
if (target !== 'claude') return 'bypassed';
if (status.nativeReadPreference) return 'native';
if (!managedToolReady) return 'setup';
if (!status.backendId) return 'unresolved';
if (status.status === 'hook-missing') return 'setup';
if (status.effectiveRuntimeMode === 'native-read') return 'fallback';
return 'active';
}
function syncManagedImageAnalysisToInstances(): void {
const instanceManager = new InstanceManager();
for (const instanceName of instanceManager.listInstances()) {
instanceManager.syncMcpServers(instanceManager.getInstancePath(instanceName));
}
}
function resolveBackendState(
status: Awaited<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>
): BackendState {
@@ -110,6 +124,7 @@ async function buildDashboardPayload() {
const config = getImageAnalysisConfig();
const { profiles, variants } = listApiProfiles();
const sharedHookInstalled = hasImageAnalyzerHook();
const managedToolReady = hasImageAnalysisMcpReady();
const profileRows = await Promise.all(
profiles.map(async (profile) => {
@@ -147,7 +162,11 @@ async function buildDashboardPayload() {
status: status.status,
effectiveRuntimeMode: status.effectiveRuntimeMode,
effectiveRuntimeReason: status.effectiveRuntimeReason,
currentTargetMode: resolveCurrentTargetMode(resolveTarget(profile.target), status),
currentTargetMode: resolveCurrentTargetMode(
resolveTarget(profile.target),
status,
managedToolReady
),
profileModel: status.profileModel,
nativeReadPreference: status.nativeReadPreference,
nativeImageCapable: status.nativeImageCapable,
@@ -191,7 +210,11 @@ async function buildDashboardPayload() {
status: status.status,
effectiveRuntimeMode: status.effectiveRuntimeMode,
effectiveRuntimeReason: status.effectiveRuntimeReason,
currentTargetMode: resolveCurrentTargetMode(resolveTarget(variant.target), status),
currentTargetMode: resolveCurrentTargetMode(
resolveTarget(variant.target),
status,
managedToolReady
),
profileModel: status.profileModel,
nativeReadPreference: status.nativeReadPreference,
nativeImageCapable: status.nativeImageCapable,
@@ -261,6 +284,11 @@ async function buildDashboardPayload() {
summaryState = 'disabled';
title = 'Disabled';
detail = 'Image is turned off globally. Images and PDFs fall back to native file access.';
} else if (!managedToolReady) {
summaryState = 'needs_setup';
title = 'Needs local runtime';
detail =
'CCS could not provision the local ImageAnalysis MCP runtime yet. Profiles will fall back to native Read until provisioning succeeds.';
} else if (backendRows.length === 0) {
summaryState = 'needs_setup';
title = 'Needs provider models';
@@ -289,6 +317,10 @@ async function buildDashboardPayload() {
bypassedProfileCount,
nativeProfileCount,
},
runtime: {
managedToolReady,
sharedHookInstalled,
},
backends: backendRows,
profiles: allProfileRows,
catalog: {
@@ -435,6 +467,13 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
};
});
const nextEnabled = body.enabled ?? currentConfig.enabled;
if (nextEnabled) {
ensureImageAnalysisMcpOrThrow();
prepareImageAnalysisFallbackHook();
syncManagedImageAnalysisToInstances();
}
res.json(await buildDashboardPayload());
} catch (error) {
res.status(500).json({ error: (error as Error).message });
+84 -40
View File
@@ -17,7 +17,7 @@
*/
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { spawnSync } from 'child_process';
import { spawn, spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
@@ -163,6 +163,56 @@ function invokeHook(
};
}
function invokeHookAsync(
input: object,
env: Record<string, string> = {}
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn('node', [HOOK_PATH], {
env: {
...process.env,
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
CCS_CLIPROXY_PORT: String(MOCK_PORT),
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: DEFAULT_PROVIDER_MODELS,
CCS_CURRENT_PROVIDER: DEFAULT_PROVIDER,
...env,
},
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error('Hook timed out'));
}, 10000);
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
clearTimeout(timer);
reject(error);
});
child.on('close', (code) => {
clearTimeout(timer);
resolve({
code: code ?? -1,
stdout,
stderr,
});
});
child.stdin.end(JSON.stringify(input));
});
}
/**
* Create a minimal valid PNG file (1x1 red pixel)
*/
@@ -272,15 +322,8 @@ function createTestTextFile(filepath: string, content: string): void {
* Create a large file exceeding 10MB
*/
function createLargeFile(filepath: string, sizeMB: number): void {
const bufferSize = 1024 * 1024; // 1MB
const totalBuffers = sizeMB;
const buffer = Buffer.alloc(bufferSize, 'A');
const stream = fs.createWriteStream(filepath);
for (let i = 0; i < totalBuffers; i++) {
stream.write(buffer);
}
stream.end();
const totalBytes = sizeMB * 1024 * 1024;
fs.writeFileSync(filepath, Buffer.alloc(totalBytes, 'A'));
}
// ============================================================================
@@ -498,12 +541,12 @@ describe('Image Analyzer Hook', () => {
// ==========================================================================
describe('File Size Limits', () => {
it('should reject files larger than 10MB', () => {
it('should reject files larger than 10MB', async () => {
// Create 11MB file
const largePath = path.join(TEST_DIR, 'large-test.png');
createLargeFile(largePath, 11);
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: largePath },
@@ -533,7 +576,7 @@ describe('Image Analyzer Hook', () => {
it('should block when CLIProxy is unavailable to prevent context overflow', async () => {
// Force hook to use a port that's definitely not running
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -553,7 +596,7 @@ describe('Image Analyzer Hook', () => {
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('CLIProxy unavailable');
});
it('should analyze PNG via mock CLIProxy and return analysis', () => {
it('should analyze PNG via mock CLIProxy and return analysis', async () => {
resetMockState();
mockResponse = {
content:
@@ -561,7 +604,7 @@ describe('Image Analyzer Hook', () => {
statusCode: 200,
};
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -577,14 +620,14 @@ describe('Image Analyzer Hook', () => {
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('red square');
});
it('should analyze JPEG via mock CLIProxy', () => {
it('should analyze JPEG via mock CLIProxy', async () => {
resetMockState();
mockResponse = {
content: 'A minimalist white image, possibly a blank canvas or placeholder.',
statusCode: 200,
};
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testJpegPath },
@@ -598,10 +641,10 @@ describe('Image Analyzer Hook', () => {
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('white image');
});
it('should include API key in request header', () => {
it('should include API key in request header', async () => {
resetMockState();
invokeHook(
await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -614,10 +657,10 @@ describe('Image Analyzer Hook', () => {
expect(lastRequest?.headers['x-api-key']).toBe(CLIPROXY_API_KEY);
});
it('should send correct request format to CLIProxy', () => {
it('should send correct request format to CLIProxy', async () => {
resetMockState();
invokeHook(
await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -627,13 +670,14 @@ describe('Image Analyzer Hook', () => {
CCS_PROFILE_TYPE: 'cliproxy',
CCS_CURRENT_PROVIDER: 'agy',
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-3-1-flash-preview',
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy',
}
);
// Verify request format
expect(lastRequest).not.toBeNull();
expect(lastRequest?.method).toBe('POST');
expect(lastRequest?.path).toBe('/v1/messages');
expect(lastRequest?.path).toBe('/api/provider/agy/v1/messages');
const body = lastRequest?.body as {
model: string;
@@ -664,10 +708,10 @@ describe('Image Analyzer Hook', () => {
expect(imageContent?.source?.data).toBeDefined();
});
it('should use correct media type for JPEG', () => {
it('should use correct media type for JPEG', async () => {
resetMockState();
invokeHook(
await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testJpegPath },
@@ -687,10 +731,10 @@ describe('Image Analyzer Hook', () => {
expect(imageContent?.source?.media_type).toBe('image/jpeg');
});
it('should respect debug mode and output debug messages', () => {
it('should respect debug mode and output debug messages', async () => {
resetMockState();
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -703,14 +747,14 @@ describe('Image Analyzer Hook', () => {
expect(result.stderr).toContain('Starting image analysis');
});
it('should handle API error response gracefully (pass through)', () => {
it('should handle API error response gracefully (pass through)', async () => {
resetMockState();
mockResponse = {
content: '',
statusCode: 500,
};
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -726,10 +770,10 @@ describe('Image Analyzer Hook', () => {
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Error');
});
it('should use model from provider_models mapping', () => {
it('should use model from provider_models mapping', async () => {
resetMockState();
invokeHook(
await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -747,8 +791,8 @@ describe('Image Analyzer Hook', () => {
expect(body.model).toBe('gpt-5.1-codex-mini'); // Model from provider_models
});
it('should skip when provider is not in provider_models', () => {
const result = invokeHook(
it('should skip when provider is not in provider_models', async () => {
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -770,10 +814,10 @@ describe('Image Analyzer Hook', () => {
// ==========================================================================
describe('Output Format Validation', () => {
it('should output valid JSON structure on success', () => {
it('should output valid JSON structure on success', async () => {
resetMockState();
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -794,10 +838,10 @@ describe('Image Analyzer Hook', () => {
expect(output.hookSpecificOutput.permissionDecisionReason).toBeDefined();
});
it('should include filename in output', () => {
it('should include filename in output', async () => {
resetMockState();
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -809,10 +853,10 @@ describe('Image Analyzer Hook', () => {
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('test-image.png');
});
it('should include model name in output', () => {
it('should include model name in output', async () => {
resetMockState();
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
@@ -826,7 +870,7 @@ describe('Image Analyzer Hook', () => {
);
});
it('should output valid JSON structure on file read error', () => {
it('should output valid JSON structure on file read error', async () => {
// Create and immediately delete file to trigger error
const errorPath = path.join(TEST_DIR, 'error-test.png');
createTestPng(errorPath);
@@ -834,7 +878,7 @@ describe('Image Analyzer Hook', () => {
// Make file unreadable (simulate permission error)
fs.chmodSync(errorPath, 0o000);
const result = invokeHook(
const result = await invokeHookAsync(
{
tool_name: 'Read',
tool_input: { file_path: errorPath },
@@ -171,15 +171,22 @@ describe('profile lifecycle service', () => {
fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n');
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 12\nwebsearch:\n enabled: false\n', 'utf8');
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => {
throw new Error('copy should not run when WebSearch is disabled');
const originalCopyFileSync = fs.copyFileSync.bind(fs);
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation((source, destination) => {
const sourcePath = String(source);
const destinationPath = String(destination);
if (sourcePath.includes('websearch') || destinationPath.includes('websearch')) {
throw new Error('websearch copy should not run when WebSearch is disabled');
}
return originalCopyFileSync(source, destination);
});
const result = await runInScopedCcsDir(() => registerApiProfileOrphans({ names: ['extra'] }));
expect(copyFileSpy).not.toHaveBeenCalled();
expect(copyFileSpy).toHaveBeenCalled();
expect(result.registered).toEqual(['extra']);
expect(result.skipped).toEqual([]);
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'websearch-transformer.cjs'))).toBe(false);
});
it('registers malformed orphan settings when force bypasses validation', async () => {
@@ -130,8 +130,14 @@ describe('profile-writer Anthropic direct', () => {
'utf8'
);
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => {
throw new Error('copy should not run when WebSearch is disabled');
const originalCopyFileSync = fs.copyFileSync.bind(fs);
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation((source, destination) => {
const sourcePath = String(source);
const destinationPath = String(destination);
if (sourcePath.includes('websearch') || destinationPath.includes('websearch')) {
throw new Error('websearch copy should not run when WebSearch is disabled');
}
return originalCopyFileSync(source, destination);
});
const result = createApiProfile(
@@ -142,9 +148,12 @@ describe('profile-writer Anthropic direct', () => {
);
expect(result.success).toBe(true);
expect(copyFileSpy).not.toHaveBeenCalled();
expect(copyFileSpy).toHaveBeenCalled();
expect(fs.existsSync(path.join(tempHome, '.ccs', 'disabled-websearch.settings.json'))).toBe(
true
);
expect(fs.existsSync(path.join(tempHome, '.ccs', 'hooks', 'websearch-transformer.cjs'))).toBe(
false
);
});
});
+99
View File
@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import ProfileRegistry from '../../../src/auth/profile-registry';
import { InstanceManager } from '../../../src/management/instance-manager';
import { handleBackup } from '../../../src/auth/commands/backup-command';
describe('auth backup command', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
let originalUnified: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auth-backup-'));
originalCcsHome = process.env.CCS_HOME;
originalUnified = process.env.CCS_UNIFIED_CONFIG;
process.env.CCS_HOME = tempHome;
process.env.CCS_UNIFIED_CONFIG = '1';
});
afterEach(() => {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnified;
else delete process.env.CCS_UNIFIED_CONFIG;
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
function writeConfig(): void {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 12',
'accounts:',
' work:',
' created: "2026-04-04T00:00:00.000Z"',
' context_mode: shared',
' context_group: default',
' continuity_mode: deeper',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
}
it('creates a JSON backup for an auth account continuity lane', async () => {
writeConfig();
const instanceMgr = new InstanceManager();
const registry = new ProfileRegistry();
const instancePath = await instanceMgr.ensureInstance('work', {
mode: 'shared',
group: 'default',
continuityMode: 'deeper',
});
fs.mkdirSync(path.join(instancePath, 'projects', 'demo-project'), { recursive: true });
fs.writeFileSync(
path.join(instancePath, 'projects', 'demo-project', 'history.jsonl'),
'{}\n',
'utf8'
);
fs.writeFileSync(path.join(instancePath, 'todos', 'todo.md'), '- keep this\n', 'utf8');
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
await handleBackup(
{
registry,
instanceMgr,
version: 'test',
},
['work', '--json']
);
} finally {
consoleSpy.mockRestore();
}
const backupBase = path.join(tempHome, '.ccs', 'backups', 'auth-continuity', 'work');
const [latestBackupDir] = fs.readdirSync(backupBase).sort().reverse();
expect(latestBackupDir).toBeTruthy();
const backupPath = path.join(backupBase, latestBackupDir as string);
expect(fs.existsSync(path.join(backupPath, 'manifest.json'))).toBe(true);
expect(
fs.existsSync(path.join(backupPath, 'projects', 'demo-project', 'history.jsonl'))
).toBe(true);
expect(fs.existsSync(path.join(backupPath, 'todos', 'todo.md'))).toBe(true);
});
});
+69
View File
@@ -195,5 +195,74 @@ describe('ProfileDetector', () => {
existsSyncSpy.mockRestore();
}
});
it('should detect cursor as a first-class runtime profile when enabled', () => {
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true);
const getCursorConfigSpy = spyOn(unifiedConfigLoader, 'getCursorConfig').mockReturnValue({
enabled: true,
port: 20129,
auto_start: true,
ghost_mode: true,
model: 'gpt-5.3-codex',
});
try {
const result = detector.detectProfileType('cursor');
expect(result.type).toBe('cursor');
expect(result.name).toBe('cursor');
expect(result.cursorConfig?.auto_start).toBe(true);
} finally {
isUnifiedModeSpy.mockRestore();
getCursorConfigSpy.mockRestore();
}
});
it('should merge default cursor fields when enabled via partial unified config', () => {
const originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempDir;
const ccsDir = path.join(tempDir, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
['version: 12', 'cursor:', ' enabled: true'].join('\n')
);
try {
const localDetector = new ProfileDetector();
const result = localDetector.detectProfileType('cursor');
expect(result.type).toBe('cursor');
expect(result.cursorConfig).toEqual({
enabled: true,
port: 20129,
auto_start: false,
ghost_mode: true,
model: 'gpt-5.3-codex',
});
} finally {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
}
});
it('should throw a helpful error when cursor profile is disabled', () => {
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true);
const getCursorConfigSpy = spyOn(unifiedConfigLoader, 'getCursorConfig').mockReturnValue({
enabled: false,
port: 20129,
auto_start: false,
ghost_mode: true,
model: 'gpt-5.3-codex',
});
try {
expect(() => detector.detectProfileType('cursor')).toThrow(/Cursor profile is not enabled/);
} finally {
isUnifiedModeSpy.mockRestore();
getCursorConfigSpy.mockRestore();
}
});
});
});
@@ -0,0 +1,143 @@
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 {
parseResumeFlagIntent,
resolveConfiguredPlainCcsResumeLane,
} from '../../../src/auth/resume-lane-diagnostics';
describe('resume lane diagnostics', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
let originalUnified: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-resume-lane-'));
originalCcsHome = process.env.CCS_HOME;
originalUnified = process.env.CCS_UNIFIED_CONFIG;
process.env.CCS_HOME = tempHome;
process.env.CCS_UNIFIED_CONFIG = '1';
});
afterEach(() => {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnified;
else delete process.env.CCS_UNIFIED_CONFIG;
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
function writeConfig(lines: string[]): void {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), `${lines.join('\n')}\n`, 'utf8');
}
it('parses implicit and explicit resume flags', () => {
expect(parseResumeFlagIntent(['-r'])).toEqual({ implicit: true });
expect(parseResumeFlagIntent(['--resume'])).toEqual({ implicit: true });
expect(parseResumeFlagIntent(['--resume', 'abc123'])).toEqual({
implicit: false,
explicitSessionId: 'abc123',
});
expect(parseResumeFlagIntent(['--resume=xyz'])).toEqual({
implicit: false,
explicitSessionId: 'xyz',
});
expect(parseResumeFlagIntent(['hello'])).toBeNull();
});
it('defaults to the native lane when no account/default mapping exists', async () => {
writeConfig([
'version: 12',
'accounts: {}',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const lane = await resolveConfiguredPlainCcsResumeLane();
expect(lane.kind).toBe('native');
expect(lane.configDir).toBe(path.join(tempHome, '.claude'));
});
it('resolves the plain ccs lane to a default account when configured', async () => {
writeConfig([
'version: 12',
'default: work',
'accounts:',
' work:',
' created: "2026-04-04T00:00:00.000Z"',
' context_mode: shared',
' context_group: default',
' continuity_mode: deeper',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const lane = await resolveConfiguredPlainCcsResumeLane();
expect(lane.kind).toBe('account-default');
expect(lane.accountName).toBe('work');
expect(lane.configDir).toBe(path.join(tempHome, '.ccs', 'instances', 'work'));
});
it('resolves the plain ccs lane to inherited account continuity when configured', async () => {
writeConfig([
'version: 12',
'accounts:',
' work:',
' created: "2026-04-04T00:00:00.000Z"',
' context_mode: shared',
' context_group: default',
' continuity_mode: deeper',
'profiles: {}',
'continuity:',
' inherit_from_account:',
' default: work',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const lane = await resolveConfiguredPlainCcsResumeLane();
expect(lane.kind).toBe('account-inherited');
expect(lane.accountName).toBe('work');
expect(lane.configDir).toBe(path.join(tempHome, '.ccs', 'instances', 'work'));
});
it('resolves a non-account default profile through continuity inheritance', async () => {
writeConfig([
'version: 12',
'default: glm',
'accounts:',
' work:',
' created: "2026-04-04T00:00:00.000Z"',
'profiles:',
' glm:',
' type: api',
' settings: ~/.ccs/glm.settings.json',
'continuity:',
' inherit_from_account:',
' glm: work',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const lane = await resolveConfiguredPlainCcsResumeLane();
expect(lane.kind).toBe('account-inherited');
expect(lane.accountName).toBe('work');
});
});
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'bun:test';
import { maybeWarnAboutResumeLaneMismatch } from '../../../src/auth/resume-lane-warning';
describe('resume lane warning', () => {
it('prints guidance when the plain ccs lane differs from the account lane', async () => {
const logs: string[] = [];
await maybeWarnAboutResumeLaneMismatch('work', '/tmp/account-lane', ['--resume'], {
log: (message) => logs.push(message),
resolvePlainLane: async () => ({
kind: 'native',
label: 'native Claude lane',
configDir: '/tmp/native-lane',
projectCount: 12,
}),
});
expect(logs[0]).toContain('Resume for account "work" will search that account lane');
expect(logs).toContain('[i] Account lane: /tmp/account-lane');
expect(logs).toContain('[i] Plain ccs lane: native Claude lane (/tmp/native-lane)');
expect(logs).toContain('[i] Recover the original lane first: ccs -r');
});
it('does not log anything when resume is not requested', async () => {
const logs: string[] = [];
await maybeWarnAboutResumeLaneMismatch('work', '/tmp/account-lane', ['hello'], {
log: (message) => logs.push(message),
resolvePlainLane: async () => {
throw new Error('should not be called');
},
});
expect(logs).toHaveLength(0);
});
it('swallows diagnostic failures and keeps the warning path non-fatal', async () => {
const logs: string[] = [];
await expect(
maybeWarnAboutResumeLaneMismatch('work', '/tmp/account-lane', ['-r'], {
debug: true,
log: (message) => logs.push(message),
resolvePlainLane: async () => {
throw new Error('broken config');
},
})
).resolves.toBeUndefined();
expect(logs[0]).toContain('Resume lane guidance skipped because diagnostics failed');
expect(logs[1]).toContain('Diagnostic error: broken config');
});
});
@@ -258,6 +258,84 @@ describe('resolveCliproxyImageAnalysisEnv', () => {
expect(result.env.CCS_CURRENT_PROVIDER).toBe('agy');
expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('0');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('https://remote.example.com:9443');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH).toBe('/api/provider/agy');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('remote-token');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED).toBe('1');
expect(result.warning).toBeNull();
});
it('pins local cliproxy image analysis to the resolved local API key', async () => {
const result = await resolveCliproxyImageAnalysisEnv(
{
profileName: 'orq',
provider: 'agy',
profileSettingsPath: '/tmp/orq.settings.json',
proxyTarget: {
host: '127.0.0.1',
port: 8317,
protocol: 'http',
isRemote: false,
},
proxyReachable: true,
},
{
getImageAnalysisHookEnv: () => ({
CCS_IMAGE_ANALYSIS_ENABLED: '1',
CCS_IMAGE_ANALYSIS_TIMEOUT: '60',
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-pro',
CCS_CURRENT_PROVIDER: 'agy',
CCS_IMAGE_ANALYSIS_SKIP: '0',
}),
hasImageAnalysisProfileHook: () => true,
hasImageAnalyzerHook: () => true,
resolveImageAnalysisRuntimeStatus: async () => createImageAnalysisStatus(),
getLocalRuntimeApiKey: () => 'local-runtime-token',
}
);
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('http://127.0.0.1:8317');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH).toBe('/api/provider/agy');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('local-runtime-token');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED).toBe('0');
expect(result.warning).toBeNull();
});
it('routes remote HTTPS image analysis through the local tunnel when available', async () => {
const result = await resolveCliproxyImageAnalysisEnv(
{
profileName: 'orq',
provider: 'agy',
profileSettingsPath: '/tmp/orq.settings.json',
proxyTarget: {
host: 'remote.example.com',
port: 9443,
protocol: 'https',
authToken: 'remote-token',
managementKey: 'remote-management-key',
allowSelfSigned: true,
isRemote: true,
},
tunnelPort: 9911,
proxyReachable: true,
},
{
getImageAnalysisHookEnv: () => ({
CCS_IMAGE_ANALYSIS_ENABLED: '1',
CCS_IMAGE_ANALYSIS_TIMEOUT: '60',
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-pro',
CCS_CURRENT_PROVIDER: 'agy',
CCS_IMAGE_ANALYSIS_SKIP: '0',
}),
hasImageAnalysisProfileHook: () => true,
hasImageAnalyzerHook: () => true,
resolveImageAnalysisRuntimeStatus: async () => createImageAnalysisStatus(),
}
);
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('http://127.0.0.1:9911');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('remote-token');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED).toBe('0');
expect(result.warning).toBeNull();
});
});
+10 -9
View File
@@ -116,17 +116,18 @@ describe('HttpsTunnelProxy', () => {
});
const port = await tunnel.start();
const net = await import('net');
const socket = new net.Socket();
// Try to connect - should work on localhost
const response = await new Promise<http.IncomingMessage>((resolve, reject) => {
const req = http.get(`http://127.0.0.1:${port}/test`, resolve);
req.on('error', reject);
req.setTimeout(1000);
}).catch((err) => err);
await new Promise<void>((resolve, reject) => {
socket.once('error', reject);
socket.connect(port, '127.0.0.1', () => {
socket.end();
resolve();
});
});
// We expect an error because there's no upstream, but connection should be accepted
// If binding failed, we'd get ECONNREFUSED before any response
expect(response).toBeDefined();
expect(tunnel.getPort()).toBe(port);
});
});
@@ -12,6 +12,7 @@ import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks';
describe('Gemini CLI Quota Fetcher', () => {
const GEMINI_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
const GEMINI_CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist';
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
let tempHome: string;
let originalCcsHome: string | undefined;
@@ -159,15 +160,29 @@ describe('Gemini CLI Quota Fetcher', () => {
const flashBucket = buckets.find((b) => b.label === 'Gemini Flash Series');
expect(flashBucket).toBeDefined();
// Takes minimum remaining fraction (0.6)
expect(flashBucket!.remainingFraction).toBe(0.6);
expect(flashBucket!.remainingPercent).toBe(60);
// Uses the preferred representative model when it exists
expect(flashBucket!.remainingFraction).toBe(0.8);
expect(flashBucket!.remainingPercent).toBe(80);
const proBucket = buckets.find((b) => b.label === 'Gemini Pro Series');
expect(proBucket).toBeDefined();
expect(proBucket!.remainingFraction).toBe(0.9);
});
it('should split Flash Lite into its own bucket', () => {
const rawBuckets = [
{ model_id: 'gemini-2.5-flash-lite', remaining_fraction: 1 },
{ model_id: 'gemini-2.5-flash', remaining_fraction: 0.7 },
];
const buckets = buildGeminiCliBuckets(rawBuckets);
expect(buckets.map((bucket) => bucket.label)).toEqual([
'Gemini Flash Lite Series',
'Gemini Flash Series',
]);
});
it('should recognize Gemini 3.1 preview IDs during the rollout', () => {
const rawBuckets = [
{ model_id: 'gemini-3.1-flash-preview', remaining_fraction: 0.7 },
@@ -242,13 +257,13 @@ describe('Gemini CLI Quota Fetcher', () => {
expect(buckets[0].remainingFraction).toBe(0.9);
});
it('should categorize unknown models as "other"', () => {
it('should preserve unknown model IDs instead of collapsing them', () => {
const rawBuckets = [{ model_id: 'unknown-model-xyz', remaining_fraction: 0.7 }];
const buckets = buildGeminiCliBuckets(rawBuckets);
expect(buckets).toHaveLength(1);
expect(buckets[0].label).toBe('Other Models');
expect(buckets[0].label).toBe('unknown-model-xyz');
});
it('should handle empty buckets array', () => {
@@ -268,7 +283,7 @@ describe('Gemini CLI Quota Fetcher', () => {
expect(buckets[0].remainingFraction).toBe(0.8);
});
it('should keep earliest reset time when merging', () => {
it('should keep the representative model reset time when it exists', () => {
const rawBuckets = [
{
model_id: 'gemini-3-flash-preview',
@@ -284,6 +299,26 @@ describe('Gemini CLI Quota Fetcher', () => {
const buckets = buildGeminiCliBuckets(rawBuckets);
const flashBucket = buckets.find((b) => b.label === 'Gemini Flash Series');
expect(flashBucket!.resetTime).toBe('2026-01-30T12:00:00Z');
});
it('should keep earliest reset time when the representative model is missing', () => {
const rawBuckets = [
{
model_id: 'gemini-3.1-flash-preview',
remaining_fraction: 0.8,
reset_time: '2026-01-30T12:00:00Z',
},
{
model_id: 'gemini-2.5-flash',
remaining_fraction: 0.6,
reset_time: '2026-01-30T10:00:00Z',
},
];
const buckets = buildGeminiCliBuckets(rawBuckets);
const flashBucket = buckets.find((b) => b.label === 'Gemini Flash Series');
expect(flashBucket!.resetTime).toBe('2026-01-30T10:00:00Z');
});
@@ -311,6 +346,147 @@ describe('Gemini CLI Quota Fetcher', () => {
});
});
describe('fetchGeminiCliQuota success metadata', () => {
it('merges tier and credit metadata into successful quota responses', async () => {
writeActiveGeminiAccount('success@example.com');
mockFetch([
{
url: GEMINI_QUOTA_URL,
method: 'POST',
status: 200,
response: {
buckets: [
{
model_id: 'gemini-2.5-flash-lite',
remaining_fraction: 1,
remaining_amount: 100,
reset_time: '2026-01-30T09:00:00Z',
},
{
model_id: 'gemini-3-flash-preview',
remaining_fraction: 0.82,
remaining_amount: 82,
reset_time: '2026-01-30T14:00:00Z',
},
{
model_id: 'gemini-2.5-flash',
remaining_fraction: 0.4,
remaining_amount: 40,
reset_time: '2026-01-30T10:00:00Z',
},
{
model_id: 'gemini-3.1-pro-preview',
remaining_fraction: 0.91,
remaining_amount: 91,
reset_time: '2026-01-30T15:00:00Z',
},
],
},
},
{
url: GEMINI_CODE_ASSIST_URL,
method: 'POST',
status: 200,
response: {
paidTier: {
id: 'g1-pro-tier',
availableCredits: [{ creditType: 'GOOGLE_ONE_AI', creditAmount: 12 }],
},
},
},
]);
const result = await fetchGeminiCliQuota('success@example.com');
expect(result.success).toBe(true);
expect(result.tierLabel).toBe('Pro');
expect(result.tierId).toBe('g1-pro-tier');
expect(result.creditBalance).toBe(12);
expect(result.buckets.map((bucket) => bucket.label)).toEqual([
'Gemini Flash Lite Series',
'Gemini Flash Series',
'Gemini Pro Series',
]);
expect(result.buckets[0].remainingAmount).toBe(100);
const flashBucket = result.buckets.find((bucket) => bucket.label === 'Gemini Flash Series');
expect(flashBucket?.remainingPercent).toBe(82);
expect(flashBucket?.remainingAmount).toBe(82);
expect(flashBucket?.resetTime).toBe('2026-01-30T14:00:00Z');
const requestUrls = getCapturedFetchRequests().map((request) => request.url);
expect(requestUrls).toContain(GEMINI_QUOTA_URL);
expect(requestUrls).toContain(GEMINI_CODE_ASSIST_URL);
});
it('keeps base quota success when supplementary metadata fails', async () => {
writeActiveGeminiAccount('supplementary-failure@example.com');
mockFetch([
{
url: GEMINI_QUOTA_URL,
method: 'POST',
status: 200,
response: {
buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.75 }],
},
},
{
url: GEMINI_CODE_ASSIST_URL,
method: 'POST',
status: 503,
response: { error: { message: 'Service unavailable' } },
},
]);
const result = await fetchGeminiCliQuota('supplementary-failure@example.com');
expect(result.success).toBe(true);
expect(result.tierLabel).toBeNull();
expect(result.tierId).toBeNull();
expect(result.creditBalance).toBeNull();
expect(result.buckets[0].remainingPercent).toBe(75);
});
it('keeps base quota success when supplementary metadata throws a network error', async () => {
writeActiveGeminiAccount('supplementary-network@example.com');
mockFetch([
{
url: GEMINI_QUOTA_URL,
method: 'POST',
status: 200,
response: {
buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.75 }],
},
},
]);
const mockedFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url === GEMINI_CODE_ASSIST_URL) {
throw new TypeError('supplementary network down');
}
return mockedFetch(input, init);
}) as typeof fetch;
try {
const result = await fetchGeminiCliQuota('supplementary-network@example.com');
expect(result.success).toBe(true);
expect(result.tierLabel).toBeNull();
expect(result.tierId).toBeNull();
expect(result.creditBalance).toBeNull();
expect(result.buckets[0].remainingPercent).toBe(75);
} finally {
globalThis.fetch = mockedFetch;
}
});
});
describe('fetchGeminiCliQuota failure metadata', () => {
it('maps 401 responses to reauth-required metadata', async () => {
writeActiveGeminiAccount('reauth@example.com');
@@ -0,0 +1,27 @@
import { describe, expect, test } from 'bun:test';
import { ROOT_COMMAND_CATALOG, getAllRootCommandTokens } from '../../../src/commands/command-catalog';
import { ROOT_COMMAND_ROUTES } from '../../../src/commands/root-command-router';
describe('command catalog', () => {
test('covers every routed root command and alias', () => {
const catalogTokens = new Set(getAllRootCommandTokens());
for (const route of ROOT_COMMAND_ROUTES) {
expect(catalogTokens.has(route.name)).toBe(true);
for (const alias of route.aliases || []) {
expect(catalogTokens.has(alias)).toBe(true);
}
}
});
test('keeps hidden operational hooks out of the public help surface', () => {
const hiddenCommands = ROOT_COMMAND_CATALOG.filter((entry) => entry.visibility === 'hidden').map(
(entry) => entry.name
);
expect(hiddenCommands).toContain('--install');
expect(hiddenCommands).toContain('--uninstall');
expect(hiddenCommands).toContain('__complete');
});
});
@@ -0,0 +1,145 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { getCompletionSuggestions } from '../../../src/commands/completion-backend';
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const originalCcsHome = process.env.CCS_HOME;
let tempHome = '';
function suggestionValues(
tokensBeforeCurrent: string[],
current = '',
shell: 'bash' | 'fish' | 'powershell' | 'zsh' = 'bash'
): string[] {
return getCompletionSuggestions({ tokensBeforeCurrent, current, shell }).map(
(entry) => entry.value
);
}
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-completion-'));
fs.mkdirSync(path.join(tempHome, '.ccs'), { recursive: true });
fs.writeFileSync(
path.join(tempHome, '.ccs', 'config.json'),
JSON.stringify(
{
profiles: {
localglm: '~/.ccs/localglm.settings.json',
},
cliproxy: {
'my-codex': {
provider: 'codex',
settings: '~/.ccs/my-codex.settings.json',
},
},
},
null,
2
)
);
fs.writeFileSync(
path.join(tempHome, '.ccs', 'profiles.json'),
JSON.stringify(
{
profiles: {
work: { type: 'account', created: '2026-04-02T00:00:00.000Z' },
},
default: 'work',
},
null,
2
)
);
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;
if (originalCcsHome === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = originalCcsHome;
fs.rmSync(tempHome, { recursive: true, force: true });
});
describe('completion backend', () => {
test('includes current root commands and dynamic profiles at the top level', () => {
const values = suggestionValues([]);
expect(values).toContain('config');
expect(values).toContain('docker');
expect(values).toContain('tokens');
expect(values).toContain('migrate');
expect(values).toContain('cursor');
expect(values).toContain('copilot');
expect(values).toContain('gemini');
expect(values).toContain('localglm');
expect(values).toContain('work');
expect(values).toContain('my-codex');
expect(values).not.toContain('__complete');
expect(values).not.toContain('--install');
expect(values).not.toContain('--uninstall');
});
test('suggests help topics from the help command', () => {
const values = suggestionValues(['help']);
expect(values).toContain('profiles');
expect(values).toContain('providers');
expect(values).toContain('completion');
expect(values).toContain('targets');
expect(values).not.toContain('api');
expect(values).not.toContain('__complete');
});
test('suggests lifecycle subcommands for api', () => {
const values = suggestionValues(['api']);
expect(values).toEqual(
expect.arrayContaining(['create', 'discover', 'copy', 'export', 'import', 'remove'])
);
});
test('suggests account profiles for auth show', () => {
const values = suggestionValues(['auth', 'show']);
expect(values).toContain('work');
expect(values).toContain('--json');
});
test('suggests default lane and account profiles for auth backup', () => {
const values = suggestionValues(['auth', 'backup']);
expect(values).toContain('default');
expect(values).toContain('work');
expect(values).toContain('--json');
});
test('suggests cliproxy variants for variant-scoped commands', () => {
const values = suggestionValues(['cliproxy', 'edit']);
expect(values).toContain('my-codex');
});
test('suggests env format values after the format flag', () => {
const values = suggestionValues(['env', '--format']);
expect(values).toEqual(
expect.arrayContaining(['openai', 'anthropic', 'raw', 'claude-extension'])
);
});
test('includes live doctor and cliproxy flags from the shared catalog', () => {
expect(suggestionValues(['doctor'])).toEqual(expect.arrayContaining(['--fix', '-f']));
expect(suggestionValues(['cliproxy'])).toEqual(expect.arrayContaining(['remove', '--backend']));
});
test('filters suggestions by the current token prefix', () => {
const values = suggestionValues([], 'do');
expect(values).toEqual(expect.arrayContaining(['docker', 'doctor']));
expect(values).not.toContain('tokens');
});
});
+47 -113
View File
@@ -1,143 +1,77 @@
import { describe, expect, test } from 'bun:test';
import { showApiCommandHelp } from '../../../src/commands/api-command/help';
import { handleHelpCommand } from '../../../src/commands/help-command';
import {
handleHelpCommand,
handleHelpRoute,
getRootHelpVisibleCommands,
} from '../../../src/commands/help-command';
function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, '');
}
async function renderLines(
render: (writeLine: (line: string) => void) => Promise<void>
): Promise<string> {
const lines: string[] = [];
await render((line) => lines.push(line));
return stripAnsi(lines.join('\n'));
}
describe('help command parity', () => {
test('root help documents cliproxy provider filter under quota command', async () => {
const lines: string[] = [];
await handleHelpCommand((line) => lines.push(line));
test('root help stays within the compact line budget', async () => {
const rendered = await renderLines((writeLine) => handleHelpCommand(writeLine));
const visibleLines = rendered.split('\n').filter((line) => line.trim().length > 0);
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('ccs cliproxy status [provider]')).toBe(false);
expect(rendered.includes('ccs cliproxy status')).toBe(true);
expect(rendered.includes('ccs cliproxy quota --provider <name>')).toBe(true);
expect(rendered.includes('ccs llamacpp')).toBe(true);
expect(visibleLines.length).toBeLessThanOrEqual(90);
expect(rendered.includes('ccs help <topic>')).toBe(true);
expect(rendered.includes('ccs help completion')).toBe(true);
});
test('root help documents llama.cpp as a local API profile', async () => {
const lines: string[] = [];
await handleHelpCommand((line) => lines.push(line));
test('root help covers every public root command once the catalog is updated', async () => {
const rendered = await renderLines((writeLine) => handleHelpCommand(writeLine));
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('ccs llamacpp')).toBe(true);
expect(rendered.includes('http://127.0.0.1:8080')).toBe(true);
for (const command of getRootHelpVisibleCommands()) {
expect(rendered.includes(command)).toBe(true);
}
});
test('root help no longer markets glmt as a supported profile', async () => {
const lines: string[] = [];
await handleHelpCommand((line) => lines.push(line));
const rendered = stripAnsi(lines.join('\n'));
test('root help no longer markets deprecated glmt directly', async () => {
const rendered = await renderLines((writeLine) => handleHelpCommand(writeLine));
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[] = [];
await handleHelpCommand((line) => lines.push(line));
test('providers topic lists built-in OAuth provider shortcuts', async () => {
const rendered = await renderLines((writeLine) => handleHelpRoute(['providers'], writeLine));
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('Claude IDE Extension setup page')).toBe(true);
expect(rendered.includes('ccs env <profile> --format claude-extension --ide vscode')).toBe(
true
);
expect(rendered.includes('ccs env <profile> --format claude-extension --ide windsurf')).toBe(
true
);
expect(rendered.includes('Built-in OAuth Providers')).toBe(true);
expect(rendered.includes('ccs cliproxy --help')).toBe(true);
expect(rendered.includes('gemini')).toBe(true);
expect(rendered.includes('codex')).toBe(true);
expect(rendered.includes('ghcp')).toBe(true);
});
test('root help documents dashboard host binding example', async () => {
const lines: string[] = [];
await handleHelpCommand((line) => lines.push(line));
test('completion topic documents install and verification paths', async () => {
const rendered = await renderLines((writeLine) => handleHelpRoute(['completion'], writeLine));
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);
expect(rendered.includes('ccs --shell-completion')).toBe(true);
expect(rendered.includes('ccs help <TAB>')).toBe(true);
expect(rendered.includes('--force')).toBe(true);
});
test('root help documents docker deployment commands', async () => {
const lines: string[] = [];
await handleHelpCommand((line) => lines.push(line));
test('api topic delegates to command-specific help', async () => {
const rendered = await renderLines((writeLine) => handleHelpRoute(['api'], writeLine));
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('ccs docker --help')).toBe(true);
expect(rendered.includes('ccs docker up --host <ssh>')).toBe(true);
expect(rendered.includes('ccs docker update')).toBe(true);
});
test('root help documents official channels native-only scope and process-env tokens', async () => {
const lines: string[] = [];
await handleHelpCommand((line) => lines.push(line));
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('Dashboard -> Settings -> Channels (fastest path)')).toBe(true);
expect(
rendered.includes(
'Fastest path: turn on the channel, save the token if needed, then run ccs.'
)
).toBe(true);
expect(rendered.includes('ccs codex')).toBe(true);
expect(rendered.includes('ccs --target codex')).toBe(true);
expect(
rendered.includes('Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work')
).toBe(true);
});
test('root help explains Claude [1m] as an explicit CCS suffix with upstream limits', async () => {
const lines: string[] = [];
await handleHelpCommand((line) => lines.push(line));
const rendered = stripAnsi(lines.join('\n'));
expect(
rendered.includes('Claude models: plain by default, opt-in with --1m or saved [1m]')
).toBe(true);
expect(rendered.includes('CCS only controls the saved [1m] suffix.')).toBe(true);
expect(rendered.includes('return 429 extra-usage errors for long-context requests')).toBe(true);
});
test('root help documents native Codex runtime alias and runtime-only scope', async () => {
const lines: string[] = [];
await handleHelpCommand((line) => lines.push(line));
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('ccs-codex <profile> [args]')).toBe(true);
expect(rendered.includes('ccsx <profile> [args]')).toBe(true);
expect(rendered.includes('ccsxp [args]')).toBe(true);
expect(rendered.includes('ccs --target codex')).toBe(true);
expect(rendered.includes('ccsxp "your prompt"')).toBe(true);
expect(rendered.includes('ccs codex-api --target codex')).toBe(true);
expect(rendered.includes('Compatible -> Codex CLI')).toBe(true);
expect(rendered.includes('CLIPROXY_API_KEY')).toBe(true);
expect(rendered.includes('codex (runtime-only)')).toBe(true);
});
test('api help documents create-time Claude [1m] flags and entitlement warning', async () => {
const lines: string[] = [];
await showApiCommandHelp((line) => lines.push(line));
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('--1m / --no-1m')).toBe(true);
expect(rendered.includes('CCS API Management')).toBe(true);
expect(rendered.includes('ccs api create --preset anthropic --1m')).toBe(true);
expect(rendered.includes('Plain Claude model IDs stay on standard context by default.')).toBe(
true
);
expect(rendered.includes('some accounts can still return 429 for long-context requests')).toBe(
true
);
expect(rendered.includes('ccs api discover --register')).toBe(true);
});
test('api help documents Codex bridge runtime launch separately from persisted targets', async () => {
const lines: string[] = [];
await showApiCommandHelp((line) => lines.push(line));
test('unknown help target shows an actionable fallback', async () => {
const rendered = await renderLines((writeLine) => handleHelpRoute(['unknown-topic'], writeLine));
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('ccs api create codex-api --cliproxy-provider codex')).toBe(true);
expect(rendered.includes('ccs codex-api --target codex')).toBe(true);
expect(rendered.includes('Default target: claude or droid (create)')).toBe(true);
expect(rendered.includes('Unknown help topic or command: unknown-topic')).toBe(true);
expect(rendered.includes('Available help topics:')).toBe(true);
process.exitCode = 0;
});
});
@@ -27,7 +27,7 @@ beforeEach(() => {
},
}));
mock.module('../../../src/commands/api-command', () => ({
mock.module('../../../src/commands/api-command/index', () => ({
handleApiCommand: async (args: string[]) => {
calls.push(`api:${args.join(' ')}`);
},
@@ -74,6 +74,15 @@ describe('root-command-router', () => {
expect(calls).toEqual([]);
});
it('does not capture cursor so bare cursor can fall through to profile routing', async () => {
const tryHandleRootCommand = await loadTryHandleRootCommand();
await expect(tryHandleRootCommand(['cursor'])).resolves.toBe(false);
await expect(tryHandleRootCommand(['cursor', 'status'])).resolves.toBe(false);
expect(calls).toEqual([]);
});
it('prints update help without invoking the updater', async () => {
const tryHandleRootCommand = await loadTryHandleRootCommand();
@@ -111,4 +120,10 @@ describe('root-command-router', () => {
await expect(tryHandleRootCommand(['tokens', 'list'])).rejects.toThrow('process.exit(37)');
});
it('routes hidden completion queries through the completion backend', async () => {
const tryHandleRootCommand = await loadTryHandleRootCommand();
await expect(tryHandleRootCommand(['__complete', '--shell', 'bash', '--current', 'do'])).resolves.toBe(true);
});
});
@@ -1,3 +1,6 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, it, expect, beforeAll, beforeEach, afterEach, mock } from 'bun:test';
type ShellTarget = 'bash' | 'zsh' | 'fish' | 'powershell' | null;
@@ -139,4 +142,15 @@ describe('shell-completion command', () => {
true
);
});
it('fish completion strips a duplicated partial token before delegating to __complete', () => {
const fishScript = readFileSync(
join(import.meta.dir, '../../../scripts/completion/ccs.fish'),
'utf8'
);
expect(fishScript).toContain('set -l current (commandline -ct)');
expect(fishScript).toContain('test "$tokens_before_current[-1]" = "$current"');
expect(fishScript).toContain('set -e tokens_before_current[-1]');
});
});
@@ -136,11 +136,15 @@ describe('generateCopilotEnv', () => {
port: 8317,
};
},
getLocalRuntimeApiKey: () => 'local-runtime-token',
});
expect(ensureCalls).toBe(1);
expect(result.env.CCS_CURRENT_PROVIDER).toBe('ghcp');
expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('0');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('http://127.0.0.1:8317');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH).toBe('/api/provider/ghcp');
expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('local-runtime-token');
expect(result.warning).toBeNull();
});
});
+196 -1
View File
@@ -2,7 +2,7 @@
* Unit tests for Cursor daemon module
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
@@ -19,7 +19,12 @@ import {
} from '../../../src/cursor/cursor-daemon';
import { getCcsDir } from '../../../src/utils/config-manager';
import { handleCursorCommand } from '../../../src/commands/cursor-command';
import {
renderCursorHelp,
renderCursorStatus,
} from '../../../src/commands/cursor-command-display';
import { loadCredentials } from '../../../src/cursor/cursor-auth';
import { DEFAULT_CURSOR_CONFIG } from '../../../src/config/unified-config-types';
// Test isolation
let originalCcsHome: string | undefined;
@@ -268,9 +273,62 @@ describe('stopDaemon', () => {
}
}
});
it('refuses to stop when daemon ownership cannot be verified', async () => {
const killSpy = spyOn(process, 'kill').mockImplementation(
((pid: number, signal?: NodeJS.Signals | number) => {
if (pid === process.pid && signal === 0) {
const err = new Error('EPERM') as NodeJS.ErrnoException;
err.code = 'EPERM';
throw err;
}
return true;
}) as typeof process.kill
);
writePidToFile(process.pid);
try {
const result = await stopDaemon();
expect(result.success).toBe(false);
expect(result.error).toContain('unable to verify daemon ownership');
expect(fs.existsSync(path.join(getTestCursorDir(), 'daemon.pid'))).toBe(true);
} finally {
killSpy.mockRestore();
removePidFile();
}
});
});
describe('handleCursorCommand', () => {
it('shows help when invoked without an admin subcommand', async () => {
const originalLog = console.log;
const originalError = console.error;
const logs: string[] = [];
const errors: string[] = [];
console.log = (...args: unknown[]) => {
logs.push(args.map((arg) => String(arg)).join(' '));
};
console.error = (...args: unknown[]) => {
errors.push(args.map((arg) => String(arg)).join(' '));
};
try {
const exitCode = await handleCursorCommand([]);
expect(exitCode).toBe(0);
expect(errors).toHaveLength(0);
expect(logs.some((line) => line.includes('Cursor IDE Integration'))).toBe(true);
expect(logs.some((line) => line.includes('Usage: ccs cursor <subcommand>'))).toBe(true);
} finally {
console.log = originalLog;
console.error = originalError;
}
});
it('returns exit code 1 for unknown subcommand', async () => {
const exitCode = await handleCursorCommand(['nonexistent']);
expect(exitCode).toBe(1);
@@ -297,3 +355,140 @@ describe('handleCursorCommand', () => {
expect(credentials?.machineId).toBe(machineId);
});
});
describe('renderCursorStatus', () => {
it('shows runtime endpoint guidance when Cursor is ready', () => {
const originalLog = console.log;
const logs: string[] = [];
console.log = (...args: unknown[]) => {
logs.push(args.map((arg) => String(arg)).join(' '));
};
try {
renderCursorStatus(
{ ...DEFAULT_CURSOR_CONFIG, enabled: true, port: 20129 },
{
authenticated: true,
expired: false,
tokenAge: 0,
credentials: {
accessToken: 'a'.repeat(60),
machineId: '1234567890abcdef1234567890abcdef',
authMethod: 'manual',
importedAt: new Date().toISOString(),
},
},
{ running: true, port: 20129, pid: 1234 }
);
expect(logs.some((line) => line.includes('OpenAI base: http://127.0.0.1:20129/v1'))).toBe(
true
);
expect(
logs.some((line) => line.includes('Chat route: http://127.0.0.1:20129/v1/chat/completions'))
).toBe(true);
expect(
logs.some((line) => line.includes('Anthropic base: http://127.0.0.1:20129'))
).toBe(true);
expect(
logs.some((line) => line.includes(`Raw settings: ${getCcsDir()}/cursor.settings.json`))
).toBe(true);
} finally {
console.log = originalLog;
}
});
it('shows runtime guidance even when setup is incomplete', () => {
const originalLog = console.log;
const logs: string[] = [];
console.log = (...args: unknown[]) => {
logs.push(args.map((arg) => String(arg)).join(' '));
};
try {
renderCursorStatus(
{ ...DEFAULT_CURSOR_CONFIG, enabled: false, port: 20129 },
{
authenticated: false,
expired: false,
tokenAge: undefined,
credentials: undefined,
},
{ running: false, port: 20129, pid: undefined }
);
expect(logs.some((line) => line.includes('OpenAI base: http://127.0.0.1:20129/v1'))).toBe(
true
);
expect(logs.some((line) => line.includes('Next steps:'))).toBe(true);
expect(logs.some((line) => line.includes(' - Help: ccs cursor help'))).toBe(true);
} finally {
console.log = originalLog;
}
});
it('falls back to ~/.ccs in status output when no CCS override is active', () => {
const originalLog = console.log;
const originalCcsHomeValue = process.env.CCS_HOME;
const logs: string[] = [];
delete process.env.CCS_HOME;
console.log = (...args: unknown[]) => {
logs.push(args.map((arg) => String(arg)).join(' '));
};
try {
renderCursorStatus(
{ ...DEFAULT_CURSOR_CONFIG, enabled: true, port: 20129 },
{
authenticated: true,
expired: false,
tokenAge: 0,
credentials: {
accessToken: 'a'.repeat(60),
machineId: '1234567890abcdef1234567890abcdef',
authMethod: 'manual',
importedAt: new Date().toISOString(),
},
},
{ running: true, port: 20129, pid: 1234 }
);
expect(logs.some((line) => line.includes('Raw settings: ~/.ccs/cursor.settings.json'))).toBe(
true
);
} finally {
if (originalCcsHomeValue !== undefined) {
process.env.CCS_HOME = originalCcsHomeValue;
} else {
delete process.env.CCS_HOME;
}
console.log = originalLog;
}
});
});
describe('renderCursorHelp', () => {
it('shows bare ccs cursor as the runtime entrypoint', () => {
const originalLog = console.log;
const logs: string[] = [];
console.log = (...args: unknown[]) => {
logs.push(args.map((arg) => String(arg)).join(' '));
};
try {
const exitCode = renderCursorHelp();
expect(exitCode).toBe(0);
expect(logs.some((line) => line.includes('Usage: ccs cursor <subcommand>'))).toBe(true);
expect(
logs.some((line) => line.includes('ccs cursor [claude args]'))
).toBe(true);
} finally {
console.log = originalLog;
}
});
});
@@ -0,0 +1,98 @@
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 {
executeCursorProfile,
generateCursorEnv,
resolveCursorImageAnalysisEnv,
} from '../../../src/cursor/cursor-profile-executor';
import { saveCredentials } from '../../../src/cursor/cursor-auth';
import type { CursorConfig } from '../../../src/config/unified-config-types';
const BASE_CONFIG: CursorConfig = {
enabled: true,
port: 20129,
auto_start: false,
ghost_mode: true,
model: 'gpt-5.3-codex',
};
describe('cursor-profile-executor', () => {
let originalCcsHome: string | undefined;
let tempDir: string;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cursor-profile-executor-'));
process.env.CCS_HOME = tempDir;
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('builds Cursor env for Claude runtime', () => {
const env = generateCursorEnv(
{
...BASE_CONFIG,
opus_model: 'cursor-opus',
sonnet_model: 'cursor-sonnet',
haiku_model: 'cursor-haiku',
},
'/tmp/claude-config'
);
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:20129');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('cursor-managed');
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('cursor-opus');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('cursor-sonnet');
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('cursor-haiku');
expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/claude-config');
});
it('skips image-analysis provider routing for cursor unless explicitly mapped', async () => {
const { env, warning } = await resolveCursorImageAnalysisEnv();
expect(env.CCS_CURRENT_PROVIDER).toBe('');
expect(env.CCS_IMAGE_ANALYSIS_SKIP).toBe('1');
expect(warning).toBeNull();
});
it('fails fast when Cursor integration is disabled', async () => {
const exitCode = await executeCursorProfile({ ...BASE_CONFIG, enabled: false }, []);
expect(exitCode).toBe(1);
});
it('fails when credentials are missing', async () => {
const exitCode = await executeCursorProfile(BASE_CONFIG, []);
expect(exitCode).toBe(1);
});
it('fails with actionable guidance when daemon is down and auto_start is false', async () => {
saveCredentials({
accessToken: 'a'.repeat(60),
machineId: '1234567890abcdef1234567890abcdef',
authMethod: 'manual',
importedAt: new Date().toISOString(),
});
const exitCode = await executeCursorProfile(
{
...BASE_CONFIG,
port: 29991,
auto_start: false,
},
[]
);
expect(exitCode).toBe(1);
});
});
+489 -5
View File
@@ -13,6 +13,7 @@ import {
import {
decodeVarint,
decodeField,
decodeMessage,
parseConnectRPCFrame,
} from '../../../src/cursor/cursor-protobuf-decoder';
import { buildCursorRequest } from '../../../src/cursor/cursor-translator';
@@ -21,6 +22,25 @@ import { CursorExecutor } from '../../../src/cursor/cursor-executor';
import { WIRE_TYPE, FIELD } from '../../../src/cursor/cursor-protobuf-schema';
import { StreamingFrameParser, decompressPayload } from '../../../src/cursor/cursor-stream-parser';
const MAX_TOOL_RESULT_CHARS = 12_000;
function computeExpectedToolResultOmittedChars(textLength: number): number {
if (textLength <= MAX_TOOL_RESULT_CHARS) {
return 0;
}
let omittedChars = textLength - MAX_TOOL_RESULT_CHARS;
while (true) {
const suffix = `\n[truncated ${omittedChars} chars]`;
const keepLength = Math.max(MAX_TOOL_RESULT_CHARS - suffix.length, 0);
const nextOmittedChars = textLength - keepLength;
if (nextOmittedChars === omittedChars) {
return omittedChars;
}
omittedChars = nextOmittedChars;
}
}
describe('Protobuf Encoding/Decoding', () => {
describe('encodeVarint / decodeVarint round-trip', () => {
it('should encode and decode 0', () => {
@@ -222,7 +242,7 @@ describe('Message Translation', () => {
expect(result.messages[0].tool_calls![0].function.name).toBe('get_weather');
});
it('should accumulate tool results', () => {
it('should flatten tool results into user tool_result blocks', () => {
const result = buildCursorRequest(
'gpt-4',
{
@@ -251,11 +271,424 @@ describe('Message Translation', () => {
{}
);
expect(result.messages).toHaveLength(3);
expect(result.messages[1].role).toBe('user');
expect(result.messages[1].content).toContain('<tool_result>');
expect(result.messages[1].content).toContain('<tool_name>get_weather</tool_name>');
expect(result.messages[1].content).toContain('<tool_call_id>call_123</tool_call_id>');
expect(result.messages[2]).toEqual({ role: 'user', content: 'What is the weather?' });
});
it('should preserve consecutive tool messages as separate user turns', () => {
const result = buildCursorRequest(
'gpt-4',
{
messages: [
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_one',
type: 'function',
function: { name: 'search_docs', arguments: '{"q":"cursor"}' },
},
{
id: 'call_two',
type: 'function',
function: { name: 'read_file', arguments: '{"path":"README.md"}' },
},
],
},
{
role: 'tool',
content: 'first result',
tool_call_id: 'call_one',
},
{
role: 'tool',
content: 'second result',
tool_call_id: 'call_two',
},
{ role: 'user', content: 'Summarize both results.' },
],
},
false,
{}
);
expect(result.messages).toHaveLength(4);
expect(result.messages[1]).toEqual({
role: 'user',
content: expect.stringContaining('<tool_call_id>call_one</tool_call_id>'),
});
expect(result.messages[2]).toEqual({
role: 'user',
content: expect.stringContaining('<tool_call_id>call_two</tool_call_id>'),
});
expect(result.messages[3]).toEqual({ role: 'user', content: 'Summarize both results.' });
});
it('should recover tool names for tool results without a name field', () => {
const result = buildCursorRequest(
'gpt-4',
{
messages: [
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_456',
type: 'function',
function: { name: 'search_docs', arguments: '{"q":"cursor"}' },
},
],
},
{
role: 'tool',
content: 'done',
tool_call_id: 'call_456',
},
],
},
false,
{}
);
expect(result.messages).toHaveLength(2);
// Tool result should be attached to next message
expect(result.messages[1].tool_results).toBeDefined();
expect(result.messages[1].tool_results).toHaveLength(1);
expect(result.messages[1].tool_results![0].tool_call_id).toBe('call_123');
expect(result.messages[1].content).toContain('<tool_name>search_docs</tool_name>');
expect(result.messages[1].tool_results).toBeUndefined();
});
it('should flatten user content arrays that include tool_result blocks', () => {
const result = buildCursorRequest(
'gpt-4',
{
messages: [
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_789',
type: 'function',
function: { name: 'search_docs', arguments: '{"q":"cursor"}' },
},
],
},
{
role: 'user',
content: [
{ type: 'text', text: 'Tool finished.' },
{
type: 'tool_result',
tool_use_id: 'call_789',
content: { answer: 'Cursor integration ready' },
},
],
},
],
},
false,
{}
);
expect(result.messages).toHaveLength(2);
expect(result.messages[1].role).toBe('user');
expect(result.messages[1].content).toContain('Tool finished.');
expect(result.messages[1].content).toContain('<tool_name>search_docs</tool_name>');
expect(result.messages[1].content).toContain('{"answer":"Cursor integration ready"}');
});
it('should preserve line breaks for multipart tool_result text content', () => {
const result = buildCursorRequest(
'gpt-4',
{
messages: [
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_lines',
type: 'function',
function: { name: 'read_file', arguments: '{"path":"notes.txt"}' },
},
],
},
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'call_lines',
content: [
{ type: 'text', text: 'first line' },
{ type: 'text', text: 'second line' },
],
},
],
},
],
},
false,
{}
);
expect(result.messages).toHaveLength(2);
expect(result.messages[1].content).toContain('<result>first line\nsecond line</result>');
});
it('should convert assistant tool_use content blocks into tool_calls', () => {
const result = buildCursorRequest(
'gpt-4',
{
messages: [
{
role: 'assistant',
content: [
{ type: 'text', text: 'Inspecting the workspace.' },
{
type: 'tool_use',
id: 'call_999',
name: 'list_files',
input: { path: '/tmp' },
},
],
},
],
},
false,
{}
);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe('assistant');
expect(result.messages[0].content).toBe('Inspecting the workspace.');
expect(result.messages[0].tool_calls).toEqual([
{
id: 'call_999',
type: 'function',
function: { name: 'list_files', arguments: '{"path":"/tmp"}' },
},
]);
});
it('should synthesize fallback ids for tool_use blocks that omit them', () => {
const result = buildCursorRequest(
'gpt-4',
{
messages: [
{
role: 'assistant',
content: [
{
type: 'tool_use',
name: 'search_docs',
input: { q: 'cursor' },
},
],
},
],
},
false,
{}
);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].tool_calls).toHaveLength(1);
expect(result.messages[0].tool_calls![0].id).toBe('toolu_cursor_fallback_0_0');
});
it('should normalize invalid assistant tool call ids before emitting tool results', () => {
const result = buildCursorRequest(
'gpt-4',
{
messages: [
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_bad\nline two',
type: 'function',
function: { name: 'search_docs', arguments: '{"q":"cursor"}' },
},
],
},
{
role: 'tool',
content: 'done',
tool_call_id: 'call_bad\nline two',
},
],
},
false,
{}
);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].tool_calls![0].id).toBe('call_bad');
expect(result.messages[1].content).toContain('<tool_call_id>call_bad</tool_call_id>');
});
it('should dedupe tool calls when assistant messages contain both tool_calls and tool_use blocks', () => {
const result = buildCursorRequest(
'gpt-4',
{
messages: [
{
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'call_dupe',
name: 'search_docs',
input: { q: 'cursor' },
},
],
tool_calls: [
{
id: 'call_dupe',
type: 'function',
function: { name: 'search_docs', arguments: '{"q":"cursor"}' },
},
],
},
],
},
false,
{}
);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].tool_calls).toEqual([
{
id: 'call_dupe',
type: 'function',
function: { name: 'search_docs', arguments: '{"q":"cursor"}' },
},
]);
});
it('should truncate oversized tool result payloads', () => {
const oversizedResult = '&'.repeat(12_050);
const omittedChars = computeExpectedToolResultOmittedChars(oversizedResult.length);
const preservedChars = oversizedResult.length - omittedChars;
const result = buildCursorRequest(
'gpt-4',
{
messages: [
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_big',
type: 'function',
function: { name: 'read_file', arguments: '{"path":"big.txt"}' },
},
],
},
{
role: 'tool',
content: oversizedResult,
tool_call_id: 'call_big',
},
],
},
false,
{}
);
expect(result.messages).toHaveLength(2);
expect(result.messages[1].content).toContain('[truncated ');
expect(result.messages[1].content).toContain(`[truncated ${omittedChars} chars]`);
const resultMatch = result.messages[1].content.match(/<result>([\s\S]*)<\/result>/);
expect(resultMatch).not.toBeNull();
expect((resultMatch?.[1].match(/&amp;/g) ?? []).length).toBe(preservedChars);
});
it('should mark unserializable structured tool results explicitly', () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
const result = buildCursorRequest(
'gpt-4',
{
messages: [
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_bad',
type: 'function',
function: { name: 'read_json', arguments: '{"path":"bad.json"}' },
},
],
},
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'call_bad',
content: circular,
},
],
},
],
},
false,
{}
);
expect(result.messages).toHaveLength(2);
expect(result.messages[1].content).toContain('[unserializable content]');
});
it('should reject tool_result blocks without a valid tool_use_id', () => {
expect(() =>
buildCursorRequest(
'gpt-4',
{
messages: [
{
role: 'user',
content: [
{
type: 'tool_result',
content: 'done',
},
],
},
],
},
false,
{}
)
).toThrow('messages[0].content[0] must include a valid tool result id');
});
it('should reject tool role messages without a valid tool_call_id', () => {
expect(() =>
buildCursorRequest(
'gpt-4',
{
messages: [
{
role: 'tool',
content: 'done',
},
],
},
false,
{}
)
).toThrow('messages[0].tool_call_id must include a valid tool result id');
});
it('should handle array content format', () => {
@@ -344,6 +777,57 @@ describe('Request Encoding', () => {
expect(result).toBeInstanceOf(Uint8Array);
expect(result.length).toBeGreaterThan(0);
});
it('should preserve flattened tool_result blocks through protobuf encoding', () => {
const translated = buildCursorRequest(
'gpt-4',
{
messages: [
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_wire',
type: 'function',
function: { name: 'read_file', arguments: '{"path":"notes.txt"}' },
},
],
},
{
role: 'tool',
content: 'workspace snapshot',
tool_call_id: 'call_wire',
},
],
},
false,
{}
);
const body = generateCursorBody(translated.messages, 'gpt-4', [], null);
const frame = parseConnectRPCFrame(Buffer.from(body));
expect(frame).not.toBeNull();
const topLevel = decodeMessage(frame!.payload);
const requestPayload = topLevel.get(FIELD.Request.REQUEST)?.[0]?.value as Uint8Array;
const chatRequest = decodeMessage(requestPayload);
const encodedMessages = (chatRequest.get(FIELD.Chat.MESSAGES) || []).map((entry) =>
decodeMessage(entry.value as Uint8Array)
);
const decoder = new TextDecoder();
const contents = encodedMessages.map((message) =>
decoder.decode(message.get(FIELD.Message.CONTENT)?.[0]?.value as Uint8Array)
);
expect(contents.some((content) => content.includes('<tool_result>'))).toBe(true);
expect(contents.some((content) => content.includes('<tool_name>read_file</tool_name>'))).toBe(
true
);
expect(
contents.some((content) => content.includes('<tool_call_id>call_wire</tool_call_id>'))
).toBe(true);
});
});
describe('Edge cases', () => {
@@ -0,0 +1,481 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { spawn } from 'child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import * as http from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const serverPath = join(process.cwd(), 'lib', 'mcp', 'ccs-image-analysis-server.cjs');
function encodeMessage(message: unknown): string {
return `${JSON.stringify(message)}\n`;
}
function encodeLegacyMessage(message: unknown): string {
const body = JSON.stringify(message);
return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
}
function collectResponses(
child: ReturnType<typeof spawn>,
expectedCount: number
): Promise<Array<Record<string, unknown>>> {
return new Promise((resolve, reject) => {
let buffer = Buffer.alloc(0);
const responses: Array<Record<string, unknown>> = [];
const timer = setTimeout(() => reject(new Error('Timed out waiting for MCP responses')), 5000);
function tryParse(): void {
while (true) {
const newlineIndex = buffer.indexOf('\n');
if (newlineIndex === -1) {
return;
}
const body = buffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim();
buffer = buffer.slice(newlineIndex + 1);
if (!body) {
continue;
}
responses.push(JSON.parse(body) as Record<string, unknown>);
if (responses.length >= expectedCount) {
clearTimeout(timer);
resolve(responses);
return;
}
}
}
child.stdout.on('data', (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
try {
tryParse();
} catch (error) {
clearTimeout(timer);
reject(error);
}
});
child.on('error', (error) => {
clearTimeout(timer);
reject(error);
});
});
}
function createTestPng(filePath: string): void {
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8,
0xcf, 0xc0, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb4, 0x00, 0x00, 0x00,
0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
]);
writeFileSync(filePath, png);
}
function createTestPdf(filePath: string): void {
writeFileSync(filePath, '%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n', 'utf8');
}
describe('ccs-image-analysis MCP server', () => {
let tempDir = '';
let imagePath = '';
let mockServer: http.Server | null = null;
afterEach(async () => {
await new Promise<void>((resolve) => {
mockServer?.close(() => resolve());
if (!mockServer) resolve();
});
mockServer = null;
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = '';
imagePath = '';
}
});
it('lists the ImageAnalysis tool and posts directly to the provider-scoped CLIProxy route', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-'));
imagePath = join(tempDir, 'screen.png');
createTestPng(imagePath);
let receivedRequest: { path?: string; apiKey?: string; body?: unknown } | null = null;
const mockPort = await new Promise<number>((resolve, reject) => {
mockServer = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
});
req.on('end', () => {
receivedRequest = {
path: req.url || '/',
apiKey: req.headers['x-api-key'] as string,
body: body ? JSON.parse(body) : null,
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
content: [{ type: 'text', text: 'The screenshot shows a red pixel debug fixture.' }],
})
);
});
});
mockServer.once('error', reject);
mockServer.listen(0, '127.0.0.1', () => {
const address = mockServer?.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to resolve mock server port'));
return;
}
resolve(address.port);
});
});
const child = spawn('node', [serverPath], {
cwd: tempDir,
env: {
...process.env,
CCS_IMAGE_ANALYSIS_ENABLED: '1',
CCS_IMAGE_ANALYSIS_SKIP: '0',
CCS_CURRENT_PROVIDER: 'agy',
CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview',
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: `http://127.0.0.1:${mockPort}`,
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy',
CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: 'test-api-key',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
try {
const responsesPromise = collectResponses(child, 3);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'bun-test', version: '1.0.0' },
},
})
);
child.stdin.write(encodeMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' }));
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: {
name: 'ImageAnalysis',
arguments: { filePath: imagePath, focus: 'Describe the visible issue' },
},
})
);
const responses = await responsesPromise;
const toolsList = responses.find((message) => message.id === 2);
const toolCall = responses.find((message) => message.id === 3);
expect(toolsList?.result).toEqual({
tools: [
{
name: 'ImageAnalysis',
description:
'Analyze a local image or PDF file with CCS provider-backed vision. Prefer this tool over Read for image and PDF paths. Use Read for text, code, and other plain files.',
inputSchema: {
type: 'object',
properties: {
filePath: {
type: 'string',
description:
'Workspace-relative path, or an absolute path inside the current workspace, to a local image or PDF file to analyze.',
},
focus: {
type: 'string',
description:
'Optional question or area of focus, for example "explain the error dialog" or "transcribe the visible text".',
},
template: {
type: 'string',
enum: ['default', 'screenshot', 'document'],
description:
'Optional prompt template override. Use screenshot for UI captures, document for PDFs/docs, or default for general images.',
},
},
required: ['filePath'],
additionalProperties: false,
},
},
],
});
expect(receivedRequest?.path).toBe('/api/provider/agy/v1/messages');
expect(receivedRequest?.apiKey).toBe('test-api-key');
expect(
(((receivedRequest?.body as { messages: Array<{ content: Array<{ text?: string }> }> })
?.messages[0]?.content[0] || {}) as { text?: string }).text
).toContain('Specific focus');
expect(toolCall?.result).toBeDefined();
expect(
((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text
).toContain('red pixel debug fixture');
expect(
((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text
).toContain('Model: gemini-3-1-flash-preview');
} finally {
child.kill();
}
});
it('returns a structured tool error when the file does not exist', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-missing-'));
const missingPath = join(tempDir, 'missing-image.png');
const child = spawn('node', [serverPath], {
cwd: tempDir,
env: {
...process.env,
CCS_IMAGE_ANALYSIS_ENABLED: '1',
CCS_IMAGE_ANALYSIS_SKIP: '0',
CCS_CURRENT_PROVIDER: 'agy',
CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
try {
const responsesPromise = collectResponses(child, 2);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'bun-test', version: '1.0.0' },
},
})
);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'ImageAnalysis',
arguments: { filePath: missingPath },
},
})
);
const responses = await responsesPromise;
const toolCall = responses.find((message) => message.id === 2);
expect(toolCall?.result).toEqual({
content: [
{
type: 'text',
text: `ImageAnalysis could not find file: ${missingPath}`,
},
],
isError: true,
});
} finally {
child.kill();
}
});
it('rejects paths outside the current workspace', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-scope-'));
const workspaceDir = join(tempDir, 'workspace');
mkdirSync(workspaceDir, { recursive: true });
const outsidePath = join(tempDir, 'outside.png');
createTestPng(outsidePath);
const child = spawn('node', [serverPath], {
cwd: workspaceDir,
env: {
...process.env,
CCS_IMAGE_ANALYSIS_ENABLED: '1',
CCS_IMAGE_ANALYSIS_SKIP: '0',
CCS_CURRENT_PROVIDER: 'agy',
CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
try {
const responsesPromise = collectResponses(child, 2);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'bun-test', version: '1.0.0' },
},
})
);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'ImageAnalysis',
arguments: { filePath: '../outside.png' },
},
})
);
const responses = await responsesPromise;
const toolCall = responses.find((message) => message.id === 2);
expect(toolCall?.error).toEqual({
code: -32602,
message: 'ImageAnalysis only allows files inside the current workspace.',
});
} finally {
child.kill();
}
});
it('sends PDF files as document blocks to the provider-scoped route', async () => {
tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-pdf-'));
const pdfPath = join(tempDir, 'manual.pdf');
createTestPdf(pdfPath);
let receivedRequest: { path?: string; apiKey?: string; body?: unknown } | null = null;
const mockPort = await new Promise<number>((resolve, reject) => {
mockServer = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
});
req.on('end', () => {
receivedRequest = {
path: req.url || '/',
apiKey: req.headers['x-api-key'] as string,
body: body ? JSON.parse(body) : null,
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
content: [{ type: 'text', text: 'The PDF contains a one-page fixture.' }],
})
);
});
});
mockServer.once('error', reject);
mockServer.listen(0, '127.0.0.1', () => {
const address = mockServer?.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to resolve mock server port'));
return;
}
resolve(address.port);
});
});
const child = spawn('node', [serverPath], {
cwd: tempDir,
env: {
...process.env,
CCS_IMAGE_ANALYSIS_ENABLED: '1',
CCS_IMAGE_ANALYSIS_SKIP: '0',
CCS_CURRENT_PROVIDER: 'agy',
CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview',
CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: `http://127.0.0.1:${mockPort}`,
CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy',
CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: 'test-api-key',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
try {
const responsesPromise = collectResponses(child, 2);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'bun-test', version: '1.0.0' },
},
})
);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'ImageAnalysis',
arguments: { filePath: pdfPath },
},
})
);
const responses = await responsesPromise;
const requestBody = receivedRequest?.body as {
messages: Array<{ content: Array<{ type: string; source?: { media_type?: string } }> }>;
};
expect(receivedRequest?.path).toBe('/api/provider/agy/v1/messages');
expect(receivedRequest?.apiKey).toBe('test-api-key');
expect(requestBody.messages[0]?.content[1]?.type).toBe('document');
expect(requestBody.messages[0]?.content[1]?.source?.media_type).toBe('application/pdf');
expect(((responses[1]?.result as { content: Array<{ text: string }> }).content[0] || {}).text).toContain(
'one-page fixture'
);
} finally {
child.kill();
}
});
it('accepts legacy Content-Length framed requests for compatibility', async () => {
const child = spawn('node', [serverPath], {
env: {
...process.env,
CCS_IMAGE_ANALYSIS_ENABLED: '1',
CCS_IMAGE_ANALYSIS_SKIP: '1',
CCS_CURRENT_PROVIDER: 'agy',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
try {
const responsesPromise = collectResponses(child, 2);
child.stdin.write(
encodeLegacyMessage({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'bun-test', version: '1.0.0' },
},
})
);
child.stdin.write(encodeLegacyMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' }));
const responses = await responsesPromise;
const toolsList = responses.find((message) => message.id === 2);
expect(toolsList?.result).toEqual({ tools: [] });
} finally {
child.kill();
}
});
});
@@ -198,6 +198,81 @@ describe('ccs-websearch MCP server', () => {
}
});
it('returns an MCP error result when DuckDuckGo responds with non-result HTML', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-websearch-mcp-server-'));
const preloadPath = join(tempDir, 'mock-fetch.cjs');
const html = `
<html>
<body>
<form action="/anomaly.js" method="post">
<input type="hidden" name="q" value="btc price" />
</form>
</body>
</html>
`.trim();
writeFileSync(
preloadPath,
`global.fetch = async () => ({ ok: true, status: 202, headers: { get: () => null }, text: async () => ${JSON.stringify(html)} });\n`,
'utf8'
);
const child = spawn('node', ['-r', preloadPath, serverPath], {
env: {
...process.env,
CCS_PROFILE_TYPE: 'settings',
CCS_WEBSEARCH_ENABLED: '1',
CCS_WEBSEARCH_SKIP: '0',
CCS_WEBSEARCH_BRAVE: '0',
CCS_WEBSEARCH_DUCKDUCKGO: '1',
CCS_WEBSEARCH_EXA: '0',
CCS_WEBSEARCH_GEMINI: '0',
CCS_WEBSEARCH_GROK: '0',
CCS_WEBSEARCH_OPENCODE: '0',
CCS_WEBSEARCH_TAVILY: '0',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
try {
const responsesPromise = collectResponses(child, 2);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'bun-test', version: '1.0.0' },
},
})
);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'WebSearch', arguments: { query: 'btc price' } },
})
);
const responses = await responsesPromise;
const toolCall = responses.find((message) => message.id === 2);
expect(toolCall?.result).toBeDefined();
expect((toolCall?.result as { isError: boolean }).isError).toBe(true);
expect(
((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text
).toContain('DuckDuckGo returned non-result HTML response');
expect(
((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text
).not.toContain('Result count: 0');
} finally {
child.kill();
rmSync(tempDir, { recursive: true, force: true });
}
});
it('accepts the legacy search alias for direct calls', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-websearch-mcp-server-'));
const preloadPath = join(tempDir, 'mock-fetch.cjs');
+87 -4
View File
@@ -31,6 +31,14 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as {
providerName: string,
content: string
) => HookOutput;
classifyDuckDuckGoHtml: (
html: string,
count: number
) => {
error?: string;
kind: 'results' | 'no_results' | 'non_result_html';
results: Array<{ title: string; url: string; description: string }>;
};
extractDuckDuckGoResults: (html: string, count: number) => Array<{
title: string;
url: string;
@@ -50,17 +58,37 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as {
parseRetryAfterSeconds: (rawValue: string) => number | null;
};
function runHookWithMockedFetch(mode: 'success' | 'failure') {
function runHookWithMockedFetch(mode: 'success' | 'empty' | 'non-result' | 'failure') {
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-'));
const preloadPath = join(tempDir, 'mock-fetch.cjs');
const html = `
const successHtml = `
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
<a class="result__snippet">Example snippet</a>
`.trim();
const emptyHtml = `
<span class="no-results">
<div class="no-results__message">
<h1>No results found for <strong>btc price</strong></h1>
</div>
</span>
`.trim();
const nonResultHtml = `
<html>
<body>
<form action="/anomaly.js" method="post">
<input type="hidden" name="q" value="btc price" />
</form>
</body>
</html>
`.trim();
const preloadScript =
mode === 'success'
? `global.fetch = async () => ({ ok: true, text: async () => ${JSON.stringify(html)} });\n`
: `global.fetch = async () => ({ ok: false, status: 503, text: async () => 'Service unavailable' });\n`;
? `global.fetch = async () => ({ ok: true, status: 200, headers: { get: () => null }, text: async () => ${JSON.stringify(successHtml)} });\n`
: mode === 'empty'
? `global.fetch = async () => ({ ok: true, status: 200, headers: { get: () => null }, text: async () => ${JSON.stringify(emptyHtml)} });\n`
: mode === 'non-result'
? `global.fetch = async () => ({ ok: true, status: 202, headers: { get: () => null }, text: async () => ${JSON.stringify(nonResultHtml)} });\n`
: `global.fetch = async () => ({ ok: false, status: 503, headers: { get: () => null }, text: async () => 'Service unavailable' });\n`;
writeFileSync(preloadPath, preloadScript, 'utf8');
@@ -149,6 +177,35 @@ describe('websearch-transformer hook helpers', () => {
});
});
it('distinguishes legitimate DuckDuckGo zero-result pages from unusable HTML', () => {
const emptyPage = `
<span class="no-results">
<div class="no-results__message">
<h1>No results found for <strong>btc price</strong></h1>
</div>
</span>
`;
const nonResultPage = `
<html>
<body>
<form action="/anomaly.js" method="post">
<input type="hidden" name="q" value="btc price" />
</form>
</body>
</html>
`;
expect(hook.classifyDuckDuckGoHtml(emptyPage, 5)).toEqual({
kind: 'no_results',
results: [],
});
expect(hook.classifyDuckDuckGoHtml(nonResultPage, 5)).toEqual({
kind: 'non_result_html',
results: [],
error: 'DuckDuckGo returned non-result HTML response (possible anti-bot/challenge page)',
});
});
it('formats structured search results for hook deny output', () => {
const formatted = hook.formatStructuredSearchResults('ccs websearch', 'DuckDuckGo', [
{
@@ -224,6 +281,18 @@ describe('websearch-transformer hook helpers', () => {
expect(output).not.toHaveProperty('additionalContext');
});
it('preserves genuine DuckDuckGo zero-result pages as successful empty searches', () => {
const result = runHookWithMockedFetch('empty');
expect(result.status).toBe(0);
expect(result.stderr.trim()).toBe('');
const output = JSON.parse(result.stdout.trim()) as HookOutput;
expect(output.hookSpecificOutput.additionalContext).toContain('Provider: DuckDuckGo');
expect(output.hookSpecificOutput.additionalContext).toContain('Result count: 0');
expect(output.hookSpecificOutput.additionalContext).toContain('No results found.');
});
it('emits runtime failure output with attempted provider details nested under hookSpecificOutput', () => {
const result = runHookWithMockedFetch('failure');
@@ -244,6 +313,20 @@ describe('websearch-transformer hook helpers', () => {
expect(output).not.toHaveProperty('additionalContext');
});
it('treats DuckDuckGo non-result HTML as provider failure instead of fake empty results', () => {
const result = runHookWithMockedFetch('non-result');
expect(result.status).toBe(0);
expect(result.stderr.trim()).toBe('');
const output = JSON.parse(result.stdout.trim()) as HookOutput;
expect(output.hookSpecificOutput.permissionDecision).toBe('deny');
expect(output.hookSpecificOutput.additionalContext).toContain(
'Attempted providers: DuckDuckGo: DuckDuckGo returned non-result HTML response'
);
expect(output.hookSpecificOutput.additionalContext).not.toContain('Result count: 0');
});
it('writes opt-in trace records with redacted query fingerprints', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-trace-'));
const preloadPath = join(tempDir, 'mock-fetch.cjs');
@@ -0,0 +1,210 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool ImageAnalysis instead of Read';
interface RunResult {
status: number | null;
stdout: string;
stderr: string;
}
function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts');
const result = spawnSync(process.execPath, [ccsEntry, ...args], {
encoding: 'utf8',
env,
timeout: 20000,
});
return {
status: result.status,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
describe('settings profile ImageAnalysis launch', () => {
let tmpHome = '';
let ccsDir = '';
let settingsPath = '';
let fakeClaudePath = '';
let claudeArgsLogPath = '';
let claudeEnvLogPath = '';
let baseEnv: NodeJS.ProcessEnv;
beforeEach(() => {
if (process.platform === 'win32') {
return;
}
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analysis-launch-'));
ccsDir = path.join(tmpHome, '.ccs');
settingsPath = path.join(ccsDir, 'glm.settings.json');
fakeClaudePath = path.join(tmpHome, 'fake-claude.sh');
claudeArgsLogPath = path.join(tmpHome, 'claude-args.txt');
claudeEnvLogPath = path.join(tmpHome, 'claude-env.txt');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: { glm: settingsPath } }, null, 2) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 12',
'websearch:',
' enabled: false',
'image_analysis:',
' enabled: true',
' timeout: 60',
' fallback_backend: agy',
' provider_models:',
' agy: gemini-3-1-flash-preview',
'cliproxy:',
' auth:',
' api_key: current-token',
'',
].join('\n'),
'utf8'
);
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/provider/agy',
ANTHROPIC_AUTH_TOKEN: 'stale-token',
ANTHROPIC_MODEL: 'glm-5',
},
},
null,
2
) + '\n'
);
fs.writeFileSync(
fakeClaudePath,
`#!/bin/sh
printf "%s\n" "$@" > "${claudeArgsLogPath}"
{
printf "runtimeApiKey=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY"
printf "runtimeBaseUrl=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL"
printf "runtimePath=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_PATH"
} > "${claudeEnvLogPath}"
exit 0
`,
{ encoding: 'utf8', mode: 0o755 }
);
fs.chmodSync(fakeClaudePath, 0o755);
baseEnv = {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CLAUDE_PATH: fakeClaudePath,
CCS_DEBUG: '1',
};
});
afterEach(() => {
if (process.platform === 'win32') {
return;
}
fs.rmSync(tmpHome, { recursive: true, force: true });
});
it('keeps launch non-fatal when the shared Read-hook fallback cannot be prepared', () => {
if (process.platform === 'win32') return;
fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8');
const result = runCcs(['glm', 'smoke'], baseEnv);
expect(result.status).toBe(0);
expect(result.stderr).not.toContain('could not prepare the local ImageAnalysis tool');
expect(fs.existsSync(claudeArgsLogPath)).toBe(true);
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
expect(launchedArgs).toContain('--append-system-prompt');
expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET);
});
it('keeps launch non-fatal when Image Analysis is disabled', () => {
if (process.platform === 'win32') return;
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
'version: 12\nwebsearch:\n enabled: false\nimage_analysis:\n enabled: false\n',
'utf8'
);
fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8');
const result = runCcs(['glm', 'smoke'], baseEnv);
expect(result.status).toBe(0);
expect(result.stderr).not.toContain('could not prepare the local ImageAnalysis tool');
expect(fs.existsSync(claudeArgsLogPath)).toBe(true);
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET);
});
it('falls back to native Read when the ImageAnalysis MCP runtime cannot be provisioned', () => {
if (process.platform === 'win32') return;
fs.writeFileSync(path.join(tmpHome, '.claude.json'), '{not-json', 'utf8');
const result = runCcs(['glm', 'smoke'], baseEnv);
expect(result.status).toBe(0);
expect(result.stderr).toContain('could not prepare the local ImageAnalysis tool');
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET);
});
it('pins bridge-backed image analysis to the current CLIProxy auth token', () => {
if (process.platform === 'win32') return;
const result = runCcs(['glm', 'smoke'], baseEnv);
expect(result.status).toBe(0);
expect(fs.existsSync(claudeEnvLogPath)).toBe(true);
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
expect(launchedEnv).toContain('runtimeApiKey=current-token');
expect(launchedEnv).not.toContain('stale-token');
expect(launchedEnv).toContain('runtimePath=/api/provider/agy');
});
it('pins direct settings image analysis to the current local CLIProxy auth token', () => {
if (process.platform === 'win32') return;
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
ANTHROPIC_AUTH_TOKEN: 'stale-token',
ANTHROPIC_MODEL: 'glm-5',
},
},
null,
2
) + '\n'
);
const result = runCcs(['glm', 'smoke'], baseEnv);
expect(result.status).toBe(0);
expect(fs.existsSync(claudeEnvLogPath)).toBe(true);
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
expect(launchedEnv).toContain('runtimeApiKey=current-token');
expect(launchedEnv).not.toContain('stale-token');
expect(launchedEnv).toContain('runtimePath=/api/provider/');
});
});
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more