mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 04:14:57 +00:00
Merge pull request #917 from kaitranntt/dev
Release: merge dev into main
This commit is contained in:
133 files changed
+11406
-4368
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -4,809 +4,147 @@
|
||||
|
||||

|
||||
|
||||
### 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://www.npmjs.com/package/@kaitranntt/ccs)
|
||||
[](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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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**
|
||||
|
||||

|
||||
|
||||
**Live Auth Monitor**
|
||||
|
||||

|
||||
|
||||
**CLI Proxy API & Copilot Integration**
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
**WebSearch Fallback**
|
||||
|
||||

|
||||
|
||||
<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).
|
||||
|
||||

|
||||
|
||||
> **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
|
||||
|
||||
[](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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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 || []),
|
||||
};
|
||||
}
|
||||
@@ -45,3 +45,4 @@ export {
|
||||
|
||||
// Executor
|
||||
export { CursorExecutor } from './cursor-executor';
|
||||
export { executeCursorProfile, generateCursorEnv } from './cursor-profile-executor';
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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';
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(/&/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');
|
||||
|
||||
@@ -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
Reference in new issue
Block a user