Merge pull request #646 from kaitranntt/dev

feat(release): promote dev to main
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-26 12:12:25 -05:00
committed by GitHub
159 changed files with 14408 additions and 764 deletions
+1 -1
View File
@@ -30,5 +30,5 @@ jobs:
- name: Build package
run: bun run build:all
- name: Validate (typecheck + lint + tests)
- name: Validate (typecheck + lint + format + maintainability [warn on PR] + tests)
run: bun run validate
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
- name: Build
run: bun run build:all
- name: Validate (typecheck + lint + tests)
- name: Validate (typecheck + lint + format + maintainability [strict] + tests)
run: bun run validate
- name: Release
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
- name: Build package
run: bun run build:all
- name: Validate (typecheck + lint + tests)
- name: Validate (typecheck + lint + format + maintainability [strict] + tests)
run: bun run validate
- name: Release
+27 -3
View File
@@ -43,6 +43,7 @@ CLI wrapper for instant switching between multiple provider accounts and alterna
| Mistake | Consequence | Correct Action |
|---------|-------------|----------------|
| Running `validate` without `format` first | format:check fails | Run `bun run format` BEFORE validate |
| Assuming maintainability check is always strict | PR/feature branches run warning mode by default | Use `bun run maintainability:check:strict` before merge when touching debt-sensitive code |
| Using `chore:` for dev→main PR | No npm release triggered | Use `feat:` or `fix:` prefix |
| Committing directly to `main` or `dev` | Bypasses CI/review | Always use PRs |
| Manual version bump or git tag | Conflicts with semantic-release | Let CI handle versioning |
@@ -59,7 +60,7 @@ Quality gates MUST pass before pushing. **Both projects have identical workflow.
# Main project (from repo root)
bun run format # Step 1: Fix formatting
bun run lint:fix # Step 2: Fix lint issues
bun run validate # Step 3: Full test gate (must pass)
bun run validate # Step 3: Full gate (typecheck + lint + format + maintainability + tests)
bun run validate:ci-parity # Step 4: CI parity gate (build + validate + base branch check)
# UI project (if UI changed)
@@ -78,7 +79,7 @@ bun run validate # Step 3: Final check (must pass)
| Project | Command | Runs |
|---------|---------|------|
| Main | `bun run validate` | typecheck + lint:fix + format:check + test:all |
| Main | `bun run validate` | typecheck + lint:fix + format:check + maintainability:check + test:all |
| UI | `bun run validate` | typecheck + lint:fix + format:check |
### ESLint Rules (ALL errors)
@@ -105,10 +106,31 @@ bun run validate # Step 3: Final check (must pass)
### Automatic Enforcement
- `prepublishOnly` / `prepack` runs `build:all` + `validate` + `sync-version.js`
- CI/CD runs `bun run validate` on every PR
- CI/CD runs `bun run validate` on every PR (maintainability is warning mode on PR events)
- husky `pre-commit` runs quick lint/type/format checks
- husky `pre-push` runs `bun run validate:ci-parity` to block CI drift before push
### Maintainability Baseline Gate
- Baseline file: `docs/metrics/maintainability-baseline.json`
- Metric collector/check script: `scripts/maintainability-baseline.js`
- Branch-aware gate wrapper: `scripts/maintainability-check.js`
- Enforcement path: `bun run maintainability:check` (included in `bun run validate`)
- Gate modes:
- `strict`: protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`) and equivalent CI refs
- `warn`: pull request CI and non-protected local branches (non-blocking for parallel PR workflow)
- override commands:
- `bun run maintainability:check:strict`
- `bun run maintainability:check:warn`
- Gated metrics (must not increase vs baseline):
- `processExitReferenceCount`
- `synchronousFsApiReferenceCount`
- `largeFileCountOver350Loc`
- Baseline update policy:
1. Prefer reducing the metric and keeping the baseline unchanged.
2. On protected-branch integration (strict mode), if increase is intentional and accepted, run `bun run maintainability:baseline`.
3. Commit both the code change and `docs/metrics/maintainability-baseline.json`, and state reason in PR description.
## Critical Constraints (NEVER VIOLATE)
1. **NO EMOJIS in CLI output** - Terminal output uses ASCII only: [OK], [!], [X], [i]
@@ -359,6 +381,8 @@ rm -rf ~/.ccs # Clean environment
- [ ] `bun run validate` — all checks pass
- [ ] `bun run validate:ci-parity` — CI parity passed (also enforced by pre-push hook)
- [ ] `cd ui && bun run format && bun run validate` — if UI changed
- [ ] If touching debt-sensitive code, run `bun run maintainability:check:strict` before opening/merging PR
- [ ] If strict mode fails and increase is intentional: `bun run maintainability:baseline` and commit `docs/metrics/maintainability-baseline.json`
**Code:**
- [ ] Conventional commit format (`feat:`, `fix:`, etc.)
+107 -4
View File
@@ -53,15 +53,19 @@ ccs config
# Opens http://localhost:3000
```
Dashboard updates hub: `http://localhost:3000/updates`
Want to run the dashboard in Docker? See `docker/README.md`.
### 3. Configure Your Accounts
The dashboard provides visual management for all account types:
- **Claude Accounts**: Create isolated instances (work, personal, client)
- **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
- **API Profiles**: Configure GLM, Kimi with your keys
- **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
**Analytics Dashboard**
@@ -154,6 +158,56 @@ ccsd glm
Need additional alias names? Set `CCS_DROID_ALIASES` as a comma-separated list (for example: `CCS_DROID_ALIASES=ccs-droid,mydroid`).
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
ccsd codex exec --skip-permissions-unsafe "fix failing tests"
ccsd codex --skip-permissions-unsafe "fix failing tests" # auto-routed to: droid exec ...
ccsd 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`
### 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
ccsd codex
ccsd agy
ccs codex --target droid
ccsd 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).
@@ -221,17 +275,66 @@ ccs work "implement feature" # Terminal 1
ccs "review code" # Terminal 2 (personal account)
```
Need continuity between two accounts for the same project? Opt in to shared context:
#### 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
# Or isolate by named group (only accounts in this group 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
```
Isolation remains the default. Shared context only links project workspace data; credentials stay per-account.
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.
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>
+4 -4
View File
@@ -2,9 +2,9 @@
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/iflow",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "deepseek-v3.2",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "kimi-k2-thinking",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v3.2",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "minimax-m2"
"ANTHROPIC_MODEL": "qwen3-coder-plus",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "qwen3-coder-plus",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "qwen3-coder-plus",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "qwen3-coder-plus"
}
}
+16 -2
View File
@@ -1,8 +1,8 @@
# CCS Codebase Summary
Last Updated: 2026-02-04
Last Updated: 2026-02-24
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, and v7.34 Image Analysis Hook.
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, and account-context validation hardening.
## Repository Structure
@@ -201,6 +201,20 @@ src/
| Services | `web-server/`, `api/` | HTTP server, API services |
| Utilities | `utils/`, `management/` | Helpers, diagnostics |
### Account Context Metadata Flow
- Source fields: `accounts.<name>.context_mode`, `accounts.<name>.context_group`, `accounts.<name>.continuity_mode` in `~/.ccs/config.yaml`.
- Runtime policy resolver: `src/auth/account-context.ts`.
- Metadata storage normalization: `src/auth/profile-registry.ts`.
- API write validation: `PUT /api/config` in `src/web-server/routes/config-routes.ts`.
- Rules:
- mode is isolation-first (`isolated` default, `shared` opt-in)
- shared mode requires non-empty valid `context_group`
- shared mode continuity depth is `standard` by default, optional `deeper`
- `context_group` is normalized (trim + lowercase + whitespace collapse to `-`)
- API route rejects `context_group`/`continuity_mode` when mode is not `shared`
- registry normalization drops malformed persisted `context_group` values
### Target Adapter Module
The targets module provides an extensible interface for dispatching profiles to different CLI implementations.
+49 -1
View File
@@ -1,6 +1,6 @@
# Dashboard Authentication CLI
Last Updated: 2026-02-04
Last Updated: 2026-02-26
CLI commands for managing CCS dashboard authentication.
@@ -10,6 +10,50 @@ The CCS dashboard (`ccs config`) can be protected with username/password authent
Authentication is **disabled by default** for backward compatibility. Use the CLI to configure and enable it.
## Account Context Modes (Related Feature)
Dashboard auth and account context metadata are separate:
- `dashboard_auth`: protects dashboard access with username/password
- `accounts.<name>.context_mode/context_group`: controls isolated vs shared account context
Account context is isolation-first:
| Mode | Default | Requirement |
|------|---------|-------------|
| `isolated` | Yes | No `context_group` required |
| `shared` | No (opt-in) | Valid non-empty `context_group` |
Shared continuity depth:
- `standard` (default): shares project workspace context only
- `deeper` (advanced opt-in): also syncs `session-env`, `file-history`, `shell-snapshots`, `todos`
`context_group` normalization and validation:
- trim + lowercase + collapse internal whitespace to `-`
- allowed characters: lowercase letters, numbers, `_`, `-`
- must start with a letter
- max length: 64
- shared mode requires non-empty value after normalization
- `continuity_mode` is only valid when mode is `shared`
`PUT /api/config` behavior for account context:
- rejects invalid unified payloads
- rejects explicit `context_mode: shared` with invalid/empty `context_group`
- rejects invalid `continuity_mode` values
- normalizes valid shared `context_group` before save
- defaults missing shared `continuity_mode` to `standard`
- rejects `context_group` when mode is not `shared`
- rejects `continuity_mode` when mode is not `shared`
Dashboard accounts context editing:
- `PUT /api/accounts/:name/context` updates context mode/group/continuity for existing auth accounts
- rejects CLIProxy OAuth account keys for this route
- applies normalization/validation rules above
## Commands
### `ccs config auth setup`
@@ -162,6 +206,10 @@ export CCS_DASHBOARD_PASSWORD_HASH='$2b$10$...'
Check `session_timeout_hours` in config. Default is 24 hours.
### "Invalid ... context_group ..."
This error comes from `PUT /api/config` when an account explicitly sets shared mode with an invalid group. Use a canonical group value (for example: `team-alpha`).
## See Also
- [Dashboard Auth Feature](https://ccs.kaitran.ca/features/dashboard-auth) - Full documentation
+4 -4
View File
@@ -1,9 +1,9 @@
{
"sourceDirectory": "src",
"largeFileThresholdLoc": 350,
"typeScriptFileCount": 355,
"locInSrc": 71353,
"typeScriptFileCount": 385,
"locInSrc": 81556,
"processExitReferenceCount": 185,
"synchronousFsApiReferenceCount": 879,
"largeFileCountOver350Loc": 56
"synchronousFsApiReferenceCount": 892,
"largeFileCountOver350Loc": 58
}
+6
View File
@@ -203,15 +203,21 @@ All criteria achieved:
## Maintainability Gate (Issue #539 Foundation)
- Baseline metrics artifact: `docs/metrics/maintainability-baseline.json`
- Branch-aware gate wrapper: `scripts/maintainability-check.js`
- Generate or refresh baseline:
- `bun run maintainability:baseline`
- `npm run maintainability:baseline`
- Run regression check gate:
- `bun run maintainability:check`
- `npm run maintainability:check`
- `bun run maintainability:check:strict` (force strict locally)
The baseline/check scripts enumerate git-tracked files under `src` for deterministic results and fail fast if git file listing is unavailable.
Default gate behavior:
- strict mode on protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`)
- warning mode on PR CI and non-protected branches (parallel PR friendly)
The check mode supports a maintainability regression gate that blocks increases in:
- `process.exit` references
- synchronous fs API references
@@ -0,0 +1,89 @@
# Session Sharing Technical Analysis
Last Updated: 2026-02-26
## Summary
CCS supports practical cross-account continuity by sharing workspace context files between selected accounts, while keeping credentials isolated per account.
This is implemented as a context policy per account:
- `isolated` (default): account keeps its own workspace context
- `shared` + `standard` (default): account workspace context is linked to a shared context group
- `shared` + `deeper` (advanced opt-in): account also shares continuity artifacts
## Why This Is Safe Enough
CCS only shares workspace context paths (project/session context files). It does **not** merge or copy authentication credentials between accounts.
Credential storage remains per account instance.
## Implementation Model
Account metadata is stored 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: "deeper"
```
Rules:
- `context_mode` must be `isolated` or `shared`
- `context_group` is required when `context_mode=shared`
- `continuity_mode` is valid only when `context_mode=shared` (`standard` or `deeper`)
- group normalization: trim, lowercase, internal spaces -> `-`
- group must start with a letter and only include `[a-zA-Z0-9_-]`
- max length: `64`
Deeper continuity links these directories per context group:
- `session-env`
- `file-history`
- `shell-snapshots`
- `todos`
`.anthropic` and account credentials remain isolated.
## User Workflows
### New account with shared context
```bash
ccs auth create work2 --share-context
ccs auth create backup --context-group sprint-a
ccs auth create backup2 --context-group sprint-a --deeper-continuity
```
### Existing account
- Open `ccs config`
- Go to `Accounts`
- Click the pencil icon (`Edit History Sync`)
- Choose `isolated` or `shared`, set group, and (optionally) choose deeper continuity
No account recreation required for this workflow.
## Current Limitations
- Shared context is local filesystem sharing. It does not bypass remote provider permission models.
- Session continuity still depends on what the upstream tool/provider stores and allows.
- Context sharing should only be enabled for accounts you intentionally trust to share workspace history.
## Alternative: CLIProxy Claude Pool
For users who prefer lower manual account switching, use CLIProxy Claude pool instead:
- Authenticate pool accounts via `ccs cliproxy auth claude`
- Manage account pool behavior in `ccs config` -> `CLIProxy Plus`
## Validation Checklist
- Confirm account row shows `shared (<group>)` in Dashboard Accounts table
- Switch between accounts in the same group and verify workspace continuity
- Run `ccs doctor` if symlink/context health looks inconsistent
+3 -3
View File
@@ -1,6 +1,6 @@
# WebSearch Configuration Guide
Last Updated: 2026-02-04
Last Updated: 2026-02-26
CCS provides automatic web search capability for all profiles, including third-party providers that cannot access Anthropic's native WebSearch API.
@@ -19,7 +19,7 @@ Third-party profiles (OAuth and API-based) cannot use Anthropic's WebSearch beca
CCS solves this with a hybrid fallback approach:
1. **Gemini CLI Transformer** (Primary) - Uses `gemini -p` with `google_web_search` tool
1. **Gemini CLI Transformer** (Primary) - Uses positional Gemini prompt mode (with legacy `-p` fallback) and `google_web_search` tool
2. **MCP Fallback Chain** (Secondary) - MCP-based web search servers
## Architecture
@@ -53,7 +53,7 @@ The **ultimate solution** for third-party WebSearch. Uses `gemini` CLI with OAut
### How It Works
1. A PreToolUse hook intercepts WebSearch tool calls
2. Executes `gemini -p` with explicit google_web_search instruction
2. Executes `gemini "<prompt>"` (positional mode) with explicit google_web_search instruction
3. Returns search results directly to Claude via the hook's deny reason
4. Claude receives full search results and continues the conversation
+83 -57
View File
@@ -282,6 +282,61 @@ async function processHook() {
/**
* Execute search via Gemini CLI
*/
function shouldRetryGeminiWithLegacyPrompt(errorMessage) {
const lowerError = (errorMessage || '').toLowerCase();
return (
lowerError.includes('unknown option') ||
lowerError.includes('unknown argument') ||
lowerError.includes('unrecognized option') ||
lowerError.includes('usage: gemini') ||
lowerError.includes('use --prompt') ||
lowerError.includes('using the --prompt option')
);
}
function runGeminiCommand(args, timeoutMs) {
const spawnResult = spawnSync('gemini', args, {
encoding: 'utf8',
timeout: timeoutMs,
maxBuffer: 1024 * 1024 * 2,
stdio: ['pipe', 'pipe', 'pipe'],
shell: isWindows,
});
if (spawnResult.error) {
if (spawnResult.error.code === 'ENOENT') {
return { success: false, error: 'Gemini CLI not installed' };
}
throw spawnResult.error;
}
if (spawnResult.status !== 0) {
const stderr = (spawnResult.stderr || '').trim();
return {
success: false,
error: stderr || `Gemini CLI exited with code ${spawnResult.status}`,
};
}
const result = (spawnResult.stdout || '').trim();
if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) {
return { success: false, error: 'Empty or too short response from Gemini' };
}
const lowerResult = result.toLowerCase();
if (
lowerResult.includes('error:') ||
lowerResult.includes('failed to') ||
lowerResult.includes('authentication required')
) {
return { success: false, error: `Gemini returned error: ${result.substring(0, 100)}` };
}
return { success: true, content: result };
}
function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
try {
const timeoutMs = timeoutSec * 1000;
@@ -290,54 +345,31 @@ function tryGeminiSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
// Allow model override via env var
const model = process.env.CCS_WEBSEARCH_GEMINI_MODEL || config.model;
const baseArgs = ['--model', model, '--yolo'];
const positionalArgs = [...baseArgs, prompt];
if (process.env.CCS_DEBUG) {
console.error(`[CCS Hook] Executing: gemini --model ${model} --yolo "..."`);
}
// Current Gemini CLI prefers positional prompts and deprecates -p/--prompt.
// Retry once with -p for legacy CLIs that still require it.
const positionalResult = runGeminiCommand(positionalArgs, timeoutMs);
if (positionalResult.success) {
return positionalResult;
}
if (!shouldRetryGeminiWithLegacyPrompt(positionalResult.error)) {
return positionalResult;
}
if (process.env.CCS_DEBUG) {
console.error('[CCS Hook] Positional Gemini prompt failed; retrying with -p for legacy CLI');
console.error(`[CCS Hook] Executing: gemini --model ${model} --yolo -p "..."`);
}
const spawnResult = spawnSync(
'gemini',
['--model', model, '--yolo', '-p', prompt],
{
encoding: 'utf8',
timeout: timeoutMs,
maxBuffer: 1024 * 1024 * 2,
stdio: ['pipe', 'pipe', 'pipe'],
shell: isWindows,
}
);
if (spawnResult.error) {
if (spawnResult.error.code === 'ENOENT') {
return { success: false, error: 'Gemini CLI not installed' };
}
throw spawnResult.error;
}
if (spawnResult.status !== 0) {
const stderr = (spawnResult.stderr || '').trim();
return {
success: false,
error: stderr || `Gemini CLI exited with code ${spawnResult.status}`,
};
}
const result = (spawnResult.stdout || '').trim();
if (!result || result.length < MIN_VALID_RESPONSE_LENGTH) {
return { success: false, error: 'Empty or too short response from Gemini' };
}
const lowerResult = result.toLowerCase();
if (
lowerResult.includes('error:') ||
lowerResult.includes('failed to') ||
lowerResult.includes('authentication required')
) {
return { success: false, error: `Gemini returned error: ${result.substring(0, 100)}` };
}
return { success: true, content: result };
const legacyPromptArgs = [...baseArgs, '-p', prompt];
return runGeminiCommand(legacyPromptArgs, timeoutMs);
} catch (err) {
if (err.killed) {
return { success: false, error: 'Gemini CLI timed out' };
@@ -362,17 +394,13 @@ function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
console.error(`[CCS Hook] Executing: opencode run --model ${model} "..."`);
}
const spawnResult = spawnSync(
'opencode',
['run', prompt, '--model', model],
{
encoding: 'utf8',
timeout: timeoutMs,
maxBuffer: 1024 * 1024 * 2,
stdio: ['pipe', 'pipe', 'pipe'],
shell: isWindows,
}
);
const spawnResult = spawnSync('opencode', ['run', prompt, '--model', model], {
encoding: 'utf8',
timeout: timeoutMs,
maxBuffer: 1024 * 1024 * 2,
stdio: ['pipe', 'pipe', 'pipe'],
shell: isWindows,
});
if (spawnResult.error) {
if (spawnResult.error.code === 'ENOENT') {
@@ -599,9 +627,7 @@ function outputNoToolsMessage(query) {
* Output all providers failed message
*/
function outputAllFailedMessage(query, errors) {
const errorDetails = errors
.map((e) => ` - ${e.provider}: ${e.error}`)
.join('\n');
const errorDetails = errors.map((e) => ` - ${e.provider}: ${e.error}`).join('\n');
const message = [
'[WebSearch - All Providers Failed]',
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.50.0",
"version": "7.50.0-dev.11",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
@@ -69,7 +69,9 @@
"validate:ci-parity": "bash scripts/ci-parity-gate.sh",
"verify:bundle": "node scripts/verify-bundle.js",
"maintainability:baseline": "node scripts/maintainability-baseline.js --out docs/metrics/maintainability-baseline.json",
"maintainability:check": "node scripts/maintainability-baseline.js --check docs/metrics/maintainability-baseline.json",
"maintainability:check": "node scripts/maintainability-check.js",
"maintainability:check:strict": "node scripts/maintainability-check.js --strict",
"maintainability:check:warn": "node scripts/maintainability-check.js --warn",
"test": "bun run build && bun run test:all",
"test:ci": "bun run test:all",
"test:all": "bun test tests/unit tests/integration tests/npm",
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env node
const { execFileSync, spawnSync } = require('child_process');
const path = require('path');
const PROJECT_ROOT = path.resolve(__dirname, '..');
const BASELINE_FILE = path.join('docs', 'metrics', 'maintainability-baseline.json');
const BASELINE_SCRIPT = path.join(PROJECT_ROOT, 'scripts', 'maintainability-baseline.js');
const PROTECTED_BRANCHES = new Set(['main', 'dev']);
const HOTFIX_PREFIXES = ['hotfix/', 'kai/hotfix-'];
function hasFlag(name) {
return process.argv.slice(2).includes(name);
}
function detectBranchName() {
try {
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: PROJECT_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
return '';
}
}
function isProtectedBranch(branchName) {
if (!branchName) {
return false;
}
if (PROTECTED_BRANCHES.has(branchName)) {
return true;
}
return HOTFIX_PREFIXES.some(prefix => branchName.startsWith(prefix));
}
function detectMode() {
if (hasFlag('--strict')) {
return 'strict';
}
if (hasFlag('--warn')) {
return 'warn';
}
if (hasFlag('--off')) {
return 'off';
}
const explicitMode = (process.env.CCS_MAINTAINABILITY_MODE || '').toLowerCase().trim();
if (explicitMode === 'strict' || explicitMode === 'warn' || explicitMode === 'off') {
return explicitMode;
}
const eventName = process.env.GITHUB_EVENT_NAME || '';
if (eventName === 'pull_request' || eventName === 'pull_request_target') {
return 'warn';
}
const gitHubRef = process.env.GITHUB_REF || '';
if (gitHubRef.startsWith('refs/heads/')) {
const branchFromRef = gitHubRef.slice('refs/heads/'.length);
if (isProtectedBranch(branchFromRef)) {
return 'strict';
}
}
return isProtectedBranch(detectBranchName()) ? 'strict' : 'warn';
}
function runBaselineCheck() {
return spawnSync('node', [BASELINE_SCRIPT, '--check', BASELINE_FILE], {
cwd: PROJECT_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
}
function writeStreams(result) {
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
}
function tryParseJson(stdout) {
if (!stdout) {
return null;
}
try {
return JSON.parse(stdout);
} catch {
return null;
}
}
function formatViolations(violations) {
if (!Array.isArray(violations) || violations.length === 0) {
return [];
}
return violations.map(violation => {
if (!violation || typeof violation !== 'object') {
return '- unknown violation';
}
const metric = violation.metric || 'unknown';
const baseline = typeof violation.baseline === 'number' ? violation.baseline : 'n/a';
const current = typeof violation.current === 'number' ? violation.current : 'n/a';
return `- ${metric}: baseline=${baseline}, current=${current}`;
});
}
function main() {
const mode = detectMode();
if (mode === 'off') {
console.log('[i] Maintainability gate disabled (mode=off).');
process.exit(0);
}
const result = runBaselineCheck();
if (mode === 'strict') {
writeStreams(result);
process.exit(result.status === null ? 1 : result.status);
}
if (result.status === 0) {
writeStreams(result);
process.exit(0);
}
const parsed = tryParseJson(result.stdout);
const branchName = detectBranchName();
console.log('[!] Maintainability regression detected (warning-only mode).');
if (branchName) {
console.log(`[i] Branch: ${branchName}`);
}
if (parsed && Array.isArray(parsed.violations) && parsed.violations.length > 0) {
console.log('[i] Violations:');
for (const line of formatViolations(parsed.violations)) {
console.log(line);
}
} else {
writeStreams(result);
}
console.log('[i] This is non-blocking on PR/feature branches to support parallel workflow.');
console.log(
'[i] Use strict mode when needed: bun run maintainability:check:strict'
);
}
main();
+2 -1
View File
@@ -15,6 +15,7 @@ export {
type ApiListResult,
type CreateApiProfileResult,
type RemoveApiProfileResult,
type UpdateApiProfileTargetResult,
} from './profile-types';
// Profile read operations
@@ -27,7 +28,7 @@ export {
} from './profile-reader';
// Profile write operations
export { createApiProfile, removeApiProfile } from './profile-writer';
export { createApiProfile, removeApiProfile, updateApiProfileTarget } from './profile-writer';
// OpenRouter catalog and picker
export { isOpenRouterUrl, fetchOpenRouterModels, type OpenRouterModel } from './openrouter-catalog';
+17 -1
View File
@@ -9,8 +9,18 @@ import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, loadConfigSafe } from '../../utils/config-manager';
import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
import type { TargetType } from '../../targets/target-adapter';
import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types';
const VALID_TARGETS: ReadonlySet<TargetType> = new Set<TargetType>(['claude', 'droid']);
function sanitizeTarget(target: unknown): TargetType {
if (typeof target === 'string' && VALID_TARGETS.has(target as TargetType)) {
return target as TargetType;
}
return 'claude';
}
/**
* Check if API profile exists in config
*/
@@ -68,6 +78,7 @@ export function listApiProfiles(): ApiListResult {
settingsPath: profile.settings || 'config.yaml',
isConfigured: isApiProfileConfigured(name),
configSource: 'unified',
target: sanitizeTarget(profile.target),
});
}
// CLIProxy variants
@@ -80,10 +91,13 @@ export function listApiProfiles(): ApiListResult {
name,
provider,
settings: variant?.settings || '-',
target: sanitizeTarget(variant?.target),
});
}
} else {
const config = loadConfigSafe();
const legacyTargetMap = (config as { profile_targets?: Record<string, unknown> })
.profile_targets;
for (const [name, settingsPath] of Object.entries(config.profiles)) {
// Skip 'default' profile - it's the user's native Claude settings
if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) {
@@ -94,16 +108,18 @@ export function listApiProfiles(): ApiListResult {
settingsPath: settingsPath as string,
isConfigured: isApiProfileConfigured(name),
configSource: 'legacy',
target: sanitizeTarget(legacyTargetMap?.[name]),
});
}
// CLIProxy variants
if (config.cliproxy) {
for (const [name, v] of Object.entries(config.cliproxy)) {
const variant = v as { provider: string; settings: string };
const variant = v as { provider: string; settings: string; target?: unknown };
variants.push({
name,
provider: variant.provider,
settings: variant.settings,
target: sanitizeTarget(variant.target),
});
}
}
+11
View File
@@ -4,6 +4,8 @@
* Shared type definitions for API profile services.
*/
import type { TargetType } from '../../targets/target-adapter';
/** Model mapping for API profiles */
export interface ModelMapping {
default: string;
@@ -18,6 +20,7 @@ export interface ApiProfileInfo {
settingsPath: string;
isConfigured: boolean;
configSource: 'unified' | 'legacy';
target: TargetType;
}
/** CLIProxy variant info */
@@ -25,6 +28,7 @@ export interface CliproxyVariantInfo {
name: string;
provider: string;
settings: string;
target: TargetType;
}
/** Result from list operation */
@@ -45,3 +49,10 @@ export interface RemoveApiProfileResult {
success: boolean;
error?: string;
}
/** Result from updating API profile target */
export interface UpdateApiProfileTargetResult {
success: boolean;
target?: TargetType;
error?: string;
}
+107 -9
View File
@@ -12,7 +12,14 @@ import {
isUnifiedMode,
} from '../../config/unified-config-loader';
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
import type { ModelMapping, CreateApiProfileResult, RemoveApiProfileResult } from './profile-types';
import type { TargetType } from '../../targets/target-adapter';
import { resolveDroidProvider } from '../../targets/droid-provider';
import type {
ModelMapping,
CreateApiProfileResult,
RemoveApiProfileResult,
UpdateApiProfileTargetResult,
} from './profile-types';
/** Check if URL is an OpenRouter endpoint */
function isOpenRouterUrl(baseUrl: string): boolean {
@@ -24,10 +31,16 @@ function createSettingsFile(
name: string,
baseUrl: string,
apiKey: string,
models: ModelMapping
models: ModelMapping,
provider?: string
): string {
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${name}.settings.json`);
const droidProvider = resolveDroidProvider({
provider,
baseUrl,
model: models.default,
});
const settings = {
env: {
@@ -37,6 +50,7 @@ function createSettingsFile(
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
CCS_DROID_PROVIDER: droidProvider,
// OpenRouter requires explicitly blanking the API key to prevent conflicts
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
},
@@ -51,11 +65,15 @@ function createSettingsFile(
}
/** Update config.json with new API profile (legacy format) */
function updateLegacyConfig(name: string): void {
function updateLegacyConfig(name: string, target: TargetType = 'claude'): void {
const configPath = getConfigPath();
const ccsDir = getCcsDir();
let config: { profiles: Record<string, string>; cliproxy?: Record<string, unknown> };
let config: {
profiles: Record<string, string>;
cliproxy?: Record<string, unknown>;
profile_targets?: Record<string, TargetType>;
};
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch {
@@ -64,6 +82,12 @@ function updateLegacyConfig(name: string): void {
const relativePath = `~/.ccs/${name}.settings.json`;
config.profiles[name] = relativePath;
config.profile_targets = config.profile_targets || {};
if (target === 'claude') {
delete config.profile_targets[name];
} else {
config.profile_targets[name] = target;
}
if (!fs.existsSync(ccsDir)) {
fs.mkdirSync(ccsDir, { recursive: true });
@@ -80,11 +104,18 @@ function createApiProfileUnified(
name: string,
baseUrl: string,
apiKey: string,
models: ModelMapping
models: ModelMapping,
target: TargetType = 'claude',
provider?: string
): void {
const ccsDir = getCcsDir();
const settingsFile = `${name}.settings.json`;
const settingsPath = path.join(ccsDir, settingsFile);
const droidProvider = resolveDroidProvider({
provider,
baseUrl,
model: models.default,
});
const settings = {
env: {
@@ -94,6 +125,7 @@ function createApiProfileUnified(
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
CCS_DROID_PROVIDER: droidProvider,
// OpenRouter requires explicitly blanking the API key to prevent conflicts
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
},
@@ -112,6 +144,7 @@ function createApiProfileUnified(
config.profiles[name] = {
type: 'api',
settings: `~/.ccs/${settingsFile}`,
...(target !== 'claude' && { target }),
};
saveUnifiedConfig(config);
}
@@ -121,16 +154,18 @@ export function createApiProfile(
name: string,
baseUrl: string,
apiKey: string,
models: ModelMapping
models: ModelMapping,
target: TargetType = 'claude',
provider?: string
): CreateApiProfileResult {
try {
const settingsFile = `~/.ccs/${name}.settings.json`;
if (isUnifiedMode()) {
createApiProfileUnified(name, baseUrl, apiKey, models);
createApiProfileUnified(name, baseUrl, apiKey, models, target, provider);
} else {
createSettingsFile(name, baseUrl, apiKey, models);
updateLegacyConfig(name);
createSettingsFile(name, baseUrl, apiKey, models, provider);
updateLegacyConfig(name, target);
}
return { success: true, settingsFile };
@@ -143,6 +178,63 @@ export function createApiProfile(
}
}
/**
* Update API profile target (claude/droid).
* Persists to config.yaml in unified mode and config.json profile_targets in legacy mode.
*/
export function updateApiProfileTarget(
name: string,
target: TargetType
): UpdateApiProfileTargetResult {
try {
if (isUnifiedMode()) {
const config = loadOrCreateUnifiedConfig();
if (!config.profiles[name]) {
return { success: false, error: `API profile not found: ${name}` };
}
if (target === 'claude') {
delete config.profiles[name].target;
} else {
config.profiles[name].target = target;
}
saveUnifiedConfig(config);
return { success: true, target };
}
const configPath = getConfigPath();
let config: {
profiles: Record<string, string>;
cliproxy?: Record<string, unknown>;
profile_targets?: Record<string, TargetType>;
};
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch {
config = { profiles: {} };
}
if (!config.profiles[name]) {
return { success: false, error: `API profile not found: ${name}` };
}
config.profile_targets = config.profile_targets || {};
if (target === 'claude') {
delete config.profile_targets[name];
} else {
config.profile_targets[name] = target;
}
const tempPath = configPath + '.tmp';
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
fs.renameSync(tempPath, configPath);
return { success: true, target };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
/** Remove API profile from unified config */
function removeApiProfileUnified(name: string): void {
const config = loadOrCreateUnifiedConfig();
@@ -175,6 +267,12 @@ function removeApiProfileUnified(name: string): void {
function removeApiProfileLegacy(name: string): void {
const config = loadConfigSafe();
delete config.profiles[name];
if (config.profile_targets) {
delete config.profile_targets[name];
if (Object.keys(config.profile_targets).length === 0) {
delete config.profile_targets;
}
}
const configPath = getConfigPath();
const tempPath = configPath + '.tmp';
+52 -8
View File
@@ -6,20 +6,24 @@
*/
export type AccountContextMode = 'isolated' | 'shared';
export type AccountContinuityMode = 'standard' | 'deeper';
export interface AccountContextMetadata {
context_mode?: AccountContextMode;
context_group?: string;
continuity_mode?: AccountContinuityMode;
}
export interface AccountContextPolicy {
mode: AccountContextMode;
group?: string;
continuityMode?: AccountContinuityMode;
}
export interface CreateAccountContextInput {
shareContext: boolean;
contextGroup?: string;
deeperContinuity?: boolean;
}
export interface ResolvedCreateAccountContext {
@@ -29,6 +33,9 @@ export interface ResolvedCreateAccountContext {
export const DEFAULT_ACCOUNT_CONTEXT_MODE: AccountContextMode = 'isolated';
export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default';
export const DEFAULT_ACCOUNT_CONTINUITY_MODE: AccountContinuityMode = 'standard';
export const MAX_CONTEXT_GROUP_LENGTH = 64;
export const ACCOUNT_PROFILE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
@@ -36,14 +43,21 @@ const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
* Normalize context group names so paths and config stay consistent.
*/
export function normalizeContextGroupName(value: string): string {
return value.trim().toLowerCase();
return value.trim().toLowerCase().replace(/\s+/g, '-');
}
/**
* Validate context group naming constraints.
*/
export function isValidContextGroupName(value: string): boolean {
return CONTEXT_GROUP_PATTERN.test(value);
return value.length <= MAX_CONTEXT_GROUP_LENGTH && CONTEXT_GROUP_PATTERN.test(value);
}
/**
* Validate account profile naming constraints.
*/
export function isValidAccountProfileName(value: string): boolean {
return ACCOUNT_PROFILE_NAME_PATTERN.test(value);
}
/**
@@ -57,11 +71,22 @@ export function isAccountContextMetadata(value: unknown): value is AccountContex
const candidate = value as Record<string, unknown>;
const mode = candidate['context_mode'];
const group = candidate['context_group'];
const continuity = candidate['continuity_mode'];
const modeValid = mode === undefined || mode === 'isolated' || mode === 'shared';
const groupValid = group === undefined || typeof group === 'string';
const continuityValid =
continuity === undefined || continuity === 'standard' || continuity === 'deeper';
return modeValid && groupValid;
if (!modeValid || !groupValid || !continuityValid) {
return false;
}
if (mode !== 'shared' && continuity !== undefined) {
return false;
}
return true;
}
/**
@@ -71,6 +96,15 @@ export function resolveCreateAccountContext(
input: CreateAccountContextInput
): ResolvedCreateAccountContext {
const hasGroupFlag = input.contextGroup !== undefined;
const continuityMode: AccountContinuityMode = input.deeperContinuity ? 'deeper' : 'standard';
if (input.deeperContinuity && !input.shareContext && !hasGroupFlag) {
return {
policy: { mode: 'isolated' },
error:
'Advanced deeper continuity requires shared context (--share-context or --context-group).',
};
}
if (hasGroupFlag) {
if (!input.contextGroup || input.contextGroup.trim().length === 0) {
@@ -84,8 +118,7 @@ export function resolveCreateAccountContext(
if (!isValidContextGroupName(normalizedGroup)) {
return {
policy: { mode: 'isolated' },
error:
'Invalid context group. Use letters/numbers/dash/underscore and start with a letter.',
error: `Invalid context group. Use letters/numbers/dash/underscore, start with a letter, max ${MAX_CONTEXT_GROUP_LENGTH} chars.`,
};
}
@@ -93,6 +126,7 @@ export function resolveCreateAccountContext(
policy: {
mode: 'shared',
group: normalizedGroup,
continuityMode,
},
};
}
@@ -102,6 +136,7 @@ export function resolveCreateAccountContext(
policy: {
mode: 'shared',
group: DEFAULT_ACCOUNT_CONTEXT_GROUP,
continuityMode,
},
};
}
@@ -120,15 +155,21 @@ export function resolveAccountContextPolicy(
const mode: AccountContextMode = metadata?.context_mode === 'shared' ? 'shared' : 'isolated';
if (mode === 'shared') {
const continuityMode: AccountContinuityMode =
metadata?.continuity_mode === 'deeper' ? 'deeper' : 'standard';
const rawGroup = metadata?.context_group;
if (rawGroup && rawGroup.trim().length > 0) {
const normalized = normalizeContextGroupName(rawGroup);
if (isValidContextGroupName(normalized)) {
return { mode: 'shared', group: normalized };
return { mode: 'shared', group: normalized, continuityMode };
}
}
return { mode: 'shared', group: DEFAULT_ACCOUNT_CONTEXT_GROUP };
return {
mode: 'shared',
group: DEFAULT_ACCOUNT_CONTEXT_GROUP,
continuityMode,
};
}
return { mode: 'isolated' };
@@ -144,6 +185,8 @@ export function policyToAccountContextMetadata(
return {
context_mode: 'shared',
context_group: policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP,
continuity_mode:
policy.continuityMode === 'deeper' ? 'deeper' : DEFAULT_ACCOUNT_CONTINUITY_MODE,
};
}
@@ -157,7 +200,8 @@ export function policyToAccountContextMetadata(
*/
export function formatAccountContextPolicy(policy: AccountContextPolicy): string {
if (policy.mode === 'shared') {
return `shared (${policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP})`;
const continuity = policy.continuityMode === 'deeper' ? 'deeper continuity' : 'standard';
return `shared (${policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP}, ${continuity})`;
}
return 'isolated';
+19
View File
@@ -14,6 +14,7 @@
import ProfileRegistry from './profile-registry';
import { InstanceManager } from '../management/instance-manager';
import { initUI, header, subheader, color, dim, warn, fail } from '../utils/ui';
import { MAX_CONTEXT_GROUP_LENGTH } from './account-context';
import packageJson from '../../package.json';
// Import command handlers from modular structure
@@ -85,6 +86,11 @@ class AuthCommands {
console.log(` ${dim('# Share context only within a specific group')}`);
console.log(` ${color('ccs auth create backup --context-group sprint-a', 'command')}`);
console.log('');
console.log(` ${dim('# Advanced: deeper shared continuity for session history artifacts')}`);
console.log(
` ${color('ccs auth create backup --context-group sprint-a --deeper-continuity', 'command')}`
);
console.log('');
console.log(` ${dim('# Set work as default')}`);
console.log(` ${color('ccs auth default work', 'command')}`);
console.log('');
@@ -107,6 +113,9 @@ class AuthCommands {
console.log(
` ${color('--context-group <name>', 'command')} Share context only within a named group`
);
console.log(
` ${color('--deeper-continuity', 'command')} Advanced shared mode: sync additional continuity artifacts`
);
console.log(
` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)`
);
@@ -127,6 +136,16 @@ class AuthCommands {
console.log(
` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.`
);
console.log(
` ${color('--deeper-continuity', 'command')} requires shared mode and syncs session-env/file-history/todos/shell-snapshots.`
);
console.log(
` Existing profiles: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.`
);
console.log(` Shared context groups are normalized (trim + lowercase) and spaces become "-".`);
console.log(
` ${color('context_group', 'path')} must be non-empty and <= ${MAX_CONTEXT_GROUP_LENGTH} chars in shared mode.`
);
console.log('');
}
+57
View File
@@ -0,0 +1,57 @@
const AMBIENT_PROVIDER_PREFIXES = [
'ANTHROPIC_',
'OPENAI_',
'GOOGLE_',
'GEMINI_',
'MINIMAX_',
'QWEN_',
'DEEPSEEK_',
'KIMI_',
'AZURE_',
'OLLAMA_',
'OPENROUTER_',
'XAI_',
'MISTRAL_',
'COHERE_',
'PERPLEXITY_',
'TOGETHER_',
'FIREWORKS_',
];
const AMBIENT_PROVIDER_EXACT_KEYS = new Set([
'OPENROUTER_API_KEY',
'OPENROUTER_KEY',
'XAI_API_KEY',
'MISTRAL_API_KEY',
'COHERE_API_KEY',
]);
const AMBIENT_PROVIDER_SUFFIXES = [
'_API_KEY',
'_AUTH_TOKEN',
'_ACCESS_TOKEN',
'_SECRET_KEY',
'_API_TOKEN',
'_BEARER_TOKEN',
'_SESSION_TOKEN',
];
export function stripAmbientProviderCredentials(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const sanitized: NodeJS.ProcessEnv = { ...env };
for (const envKey of Object.keys(sanitized)) {
const normalizedKey = envKey.toUpperCase();
if (normalizedKey === 'CLAUDE_CONFIG_DIR') {
continue;
}
if (
AMBIENT_PROVIDER_PREFIXES.some((prefix) => normalizedKey.startsWith(prefix)) ||
AMBIENT_PROVIDER_EXACT_KEYS.has(normalizedKey) ||
AMBIENT_PROVIDER_SUFFIXES.some((suffix) => normalizedKey.endsWith(suffix))
) {
delete sanitized[envKey];
}
}
return sanitized;
}
+157 -31
View File
@@ -9,27 +9,48 @@ import { initUI, header, color, fail, warn, info, infoBox, warnBox } from '../..
import { getClaudeCliInfo } from '../../utils/claude-detector';
import { escapeShellArg, stripClaudeCodeEnv } from '../../utils/shell-executor';
import { isUnifiedMode } from '../../config/unified-config-loader';
import { ProfileMetadata } from '../../types';
import {
resolveCreateAccountContext,
policyToAccountContextMetadata,
formatAccountContextPolicy,
isValidAccountProfileName,
resolveAccountContextPolicy,
} from '../account-context';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { CommandContext, parseArgs } from './types';
import { stripAmbientProviderCredentials } from './create-command-env';
function sanitizeProfileNameForInstance(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
}
/**
* Handle the create command
*/
export async function handleCreate(ctx: CommandContext, args: string[]): Promise<void> {
await initUI();
const { profileName, force, shareContext, contextGroup } = parseArgs(args);
const { profileName, force, shareContext, contextGroup, deeperContinuity, unknownFlags } =
parseArgs(args);
if (unknownFlags && unknownFlags.length > 0) {
const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', ');
console.log(fail(`Unknown option(s): ${unknownList}`));
console.log('');
console.log(
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>] [--deeper-continuity]', 'command')}`
);
console.log(`Help: ${color('ccs auth --help', 'command')}`);
console.log('');
exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR);
}
if (!profileName) {
console.log(fail('Profile name is required'));
console.log('');
console.log(
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>]', 'command')}`
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>] [--deeper-continuity]', 'command')}`
);
console.log('');
console.log('Example:');
@@ -37,6 +58,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
}
if (!isValidAccountProfileName(profileName)) {
const error =
'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.';
console.log(fail(error));
console.log('');
exitWithError(error, ExitCode.PROFILE_ERROR);
}
// Check if profile already exists (check both legacy and unified)
const existsLegacy = ctx.registry.hasProfile(profileName);
const existsUnified = ctx.registry.hasAccountUnified(profileName);
@@ -46,9 +75,22 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
exitWithError(`Profile already exists: ${profileName}`, ExitCode.PROFILE_ERROR);
}
const normalizedName = sanitizeProfileNameForInstance(profileName);
const collidingName = Object.keys(ctx.registry.getAllProfilesMerged()).find(
(name) => name !== profileName && sanitizeProfileNameForInstance(name) === normalizedName
);
if (collidingName) {
const error = `Profile "${profileName}" conflicts with existing profile "${collidingName}" on filesystem.`;
console.log(fail(error));
console.log('');
exitWithError(error, ExitCode.PROFILE_ERROR);
}
const resolvedContext = resolveCreateAccountContext({
shareContext: !!shareContext,
contextGroup,
deeperContinuity: !!deeperContinuity,
});
if (resolvedContext.error) {
@@ -59,6 +101,80 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
const contextPolicy = resolvedContext.policy;
const contextMetadata = policyToAccountContextMetadata(contextPolicy);
const useUnifiedConfig = isUnifiedMode();
const profileExistedBeforeCreate = existsLegacy || existsUnified;
const createdUnifiedProfile = useUnifiedConfig && !existsUnified;
const createdLegacyProfile = !useUnifiedConfig && !existsLegacy;
const previousLegacyProfile: ProfileMetadata | undefined = existsLegacy
? ctx.registry.getProfile(profileName)
: undefined;
const previousUnifiedProfile = existsUnified
? ctx.registry.getAllAccountsUnified()[profileName]
: undefined;
const previousContextPolicy =
profileExistedBeforeCreate && (previousUnifiedProfile || previousLegacyProfile)
? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile)
: undefined;
const claudeInfo = getClaudeCliInfo();
if (!claudeInfo) {
console.log(fail('Claude CLI not found'));
console.log('');
console.log('Please install Claude CLI first:');
console.log(` ${color('https://claude.ai/download', 'path')}`);
exitWithError('Claude CLI not found', ExitCode.BINARY_ERROR);
}
let rollbackCompleted = false;
const rollbackMetadata = (): void => {
try {
if (useUnifiedConfig) {
if (createdUnifiedProfile) {
if (ctx.registry.hasAccountUnified(profileName)) {
ctx.registry.removeAccountUnified(profileName);
}
} else if (previousUnifiedProfile) {
ctx.registry.updateAccountUnified(profileName, previousUnifiedProfile);
}
} else {
if (createdLegacyProfile) {
if (ctx.registry.hasProfile(profileName)) {
ctx.registry.deleteProfile(profileName);
}
} else if (previousLegacyProfile) {
ctx.registry.updateProfile(profileName, previousLegacyProfile);
}
}
} catch {
// Best-effort rollback to avoid leaving stale accounts after failed login.
}
};
const rollbackFailedCreate = async (): Promise<void> => {
if (rollbackCompleted) {
return;
}
rollbackCompleted = true;
rollbackMetadata();
if (!profileExistedBeforeCreate) {
try {
ctx.instanceMgr.deleteInstance(profileName);
} catch {
// Best-effort cleanup.
}
return;
}
if (previousContextPolicy) {
try {
await ctx.instanceMgr.ensureInstance(profileName, previousContextPolicy);
} catch {
// Best-effort rollback for context mode/group.
}
}
};
try {
// Create instance directory
@@ -66,7 +182,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy);
// Create/update profile entry based on config mode
if (isUnifiedMode()) {
if (useUnifiedConfig) {
// Use unified config (config.yaml)
if (existsUnified) {
ctx.registry.updateAccountUnified(profileName, {
@@ -96,43 +212,49 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
console.log(info(`Instance directory: ${instancePath}`));
console.log('');
console.log(warn('Starting Claude in isolated instance...'));
const launchDescription =
contextPolicy.mode === 'shared'
? contextPolicy.continuityMode === 'deeper'
? `Starting Claude with shared context group "${contextPolicy.group || 'default'}" (deeper continuity)...`
: `Starting Claude with shared context group "${contextPolicy.group || 'default'}"...`
: 'Starting Claude in isolated instance...';
console.log(warn(launchDescription));
console.log(warn('You will be prompted to login with your account.'));
console.log('');
// Detect Claude CLI
const claudeInfo = getClaudeCliInfo();
if (!claudeInfo) {
console.log(fail('Claude CLI not found'));
console.log('');
console.log('Please install Claude CLI first:');
console.log(` ${color('https://claude.ai/download', 'path')}`);
exitWithError('Claude CLI not found', ExitCode.BINARY_ERROR);
}
const { path: claudeCli, needsShell } = claudeInfo;
const childEnv = stripClaudeCodeEnv({ ...process.env, CLAUDE_CONFIG_DIR: instancePath });
const childEnv = stripAmbientProviderCredentials(
stripClaudeCodeEnv({ ...process.env, CLAUDE_CONFIG_DIR: instancePath })
);
// Execute Claude in isolated instance (will auto-prompt for login if no credentials)
// On Windows, .cmd/.bat/.ps1 files need shell: true to execute properly
let child: ChildProcess;
if (needsShell) {
const cmdString = escapeShellArg(claudeCli);
child = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: true,
env: childEnv,
});
} else {
child = spawn(claudeCli, [], {
stdio: 'inherit',
windowsHide: true,
env: childEnv,
});
try {
if (needsShell) {
const cmdString = escapeShellArg(claudeCli);
child = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: true,
env: childEnv,
});
} else {
child = spawn(claudeCli, [], {
stdio: 'inherit',
windowsHide: true,
env: childEnv,
});
}
} catch (error) {
await rollbackFailedCreate();
exitWithError(
`Failed to execute Claude CLI: ${(error as Error).message}`,
ExitCode.BINARY_ERROR
);
}
child.on('exit', (code: number | null) => {
child.on('exit', async (code: number | null) => {
if (code === 0) {
console.log('');
console.log(
@@ -162,6 +284,8 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
console.log('');
process.exit(0);
} else {
await rollbackFailedCreate();
console.log('');
console.log(fail('Login failed or cancelled'));
console.log('');
@@ -172,10 +296,12 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
}
});
child.on('error', (err: Error) => {
child.on('error', async (err: Error) => {
await rollbackFailedCreate();
exitWithError(`Failed to execute Claude CLI: ${err.message}`, ExitCode.BINARY_ERROR);
});
} catch (error) {
await rollbackFailedCreate();
exitWithError(`Failed to create profile: ${(error as Error).message}`, ExitCode.GENERAL_ERROR);
}
}
+5 -1
View File
@@ -30,6 +30,9 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
type: 'account',
created: account.created,
last_used: account.last_used,
context_mode: account.context_mode,
context_group: account.context_group,
continuity_mode: account.continuity_mode,
};
}
@@ -54,6 +57,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
last_used: profile.last_used || null,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group || null,
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
instance_path: instancePath,
};
}),
@@ -132,7 +136,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
console.log(
table(rows, {
head: headers,
colWidths: verbose ? [15, 12, 15, 12, 18] : [15, 12, 15],
colWidths: verbose ? [15, 12, 15, 12, 34] : [15, 12, 15],
})
);
console.log('');
+1
View File
@@ -60,6 +60,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
last_used: profile.last_used || null,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group || null,
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
instance_path: instancePath,
session_count: sessionCount,
};
+28
View File
@@ -21,6 +21,8 @@ export interface AuthCommandArgs {
yes?: boolean;
shareContext?: boolean;
contextGroup?: string;
deeperContinuity?: boolean;
unknownFlags?: string[];
}
/**
@@ -34,6 +36,7 @@ export interface ProfileOutput {
last_used: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string | null;
continuity_mode?: 'standard' | 'deeper' | null;
instance_path?: string;
session_count?: number;
}
@@ -61,6 +64,17 @@ export interface CommandContext {
export function parseArgs(args: string[]): AuthCommandArgs {
let profileName: string | undefined;
let contextGroup: string | undefined;
const unknownFlags = new Set<string>();
const knownBooleanFlags = new Set([
'--force',
'--verbose',
'--json',
'--yes',
'-y',
'--share-context',
'--deeper-continuity',
]);
const knownValueFlags = new Set(['--context-group']);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
@@ -83,6 +97,18 @@ export function parseArgs(args: string[]): AuthCommandArgs {
}
if (arg.startsWith('-')) {
const normalizedFlag = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
const isKnownFlag =
knownBooleanFlags.has(normalizedFlag) || knownValueFlags.has(normalizedFlag);
if (!isKnownFlag) {
unknownFlags.add(normalizedFlag);
// Best effort: unknown flags often take a value token.
// Skip one following non-flag token to avoid mis-parsing profile name.
const next = args[i + 1];
if (!arg.includes('=') && next && !next.startsWith('-')) {
i++;
}
}
continue;
}
@@ -98,6 +124,8 @@ export function parseArgs(args: string[]): AuthCommandArgs {
json: args.includes('--json'),
yes: args.includes('--yes') || args.includes('-y'),
shareContext: args.includes('--share-context'),
deeperContinuity: args.includes('--deeper-continuity'),
contextGroup,
unknownFlags: [...unknownFlags],
};
}
+8
View File
@@ -202,6 +202,7 @@ class ProfileDetector {
last_used: account.last_used,
context_mode: account.context_mode,
context_group: account.context_group,
continuity_mode: account.continuity_mode,
},
};
}
@@ -309,12 +310,15 @@ class ProfileDetector {
// Priority 2: Check user-defined CLIProxy variants (config.cliproxy section)
const config = this.readConfig();
const legacyTargetMap = (config as { profile_targets?: Record<string, TargetType> })
.profile_targets;
if (config.cliproxy && config.cliproxy[profileName]) {
const variant = config.cliproxy[profileName];
return {
type: 'cliproxy',
name: profileName,
target: variant.target,
provider: variant.provider as CLIProxyProfileName,
settingsPath: variant.settings,
port: variant.port,
@@ -333,6 +337,7 @@ class ProfileDetector {
type: 'settings',
name: profileName,
settingsPath: config.profiles[candidate],
target: legacyTargetMap?.[candidate],
message: viaLegacyAlias
? `Using legacy API profile "${candidate}" for "${profileName}".`
: undefined,
@@ -392,6 +397,8 @@ class ProfileDetector {
// Check if settings-based default exists
const config = this.readConfig();
const legacyTargetMap = (config as { profile_targets?: Record<string, TargetType> })
.profile_targets;
if (config.profiles && config.profiles['default']) {
const settingsPath = config.profiles['default'];
@@ -409,6 +416,7 @@ class ProfileDetector {
type: 'settings',
name: 'default',
settingsPath,
target: legacyTargetMap?.['default'],
};
}
+50 -7
View File
@@ -8,6 +8,7 @@ import {
} from '../config/unified-config-loader';
import type { AccountConfig } from '../config/unified-config-types';
import { getCcsDir } from '../utils/config-manager';
import { isValidContextGroupName, normalizeContextGroupName } from './account-context';
/**
* Profile Registry (Simplified)
@@ -22,6 +23,7 @@ import { getCcsDir } from '../utils/config-manager';
* last_used: <ISO timestamp or null> // Last usage time
* context_mode?: 'isolated' | 'shared' // Workspace context policy
* context_group?: <string> // Shared context group when mode=shared
* continuity_mode?: 'standard' | 'deeper' // Shared continuity depth
* }
*
* Removed fields from v2.x:
@@ -42,6 +44,7 @@ interface CreateMetadata {
last_used?: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string;
continuity_mode?: 'standard' | 'deeper';
}
export class ProfileRegistry {
@@ -51,13 +54,34 @@ export class ProfileRegistry {
this.profilesPath = path.join(getCcsDir(), 'profiles.json');
}
private normalizeContextGroupValue(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = normalizeContextGroupName(value);
if (normalized.length === 0 || !isValidContextGroupName(normalized)) {
return undefined;
}
return normalized;
}
private normalizeLegacyProfileMetadata(metadata: ProfileMetadata): ProfileMetadata {
const normalized: ProfileMetadata = { ...metadata };
if (normalized.context_mode !== 'shared') {
delete normalized.context_group;
} else if (!normalized.context_group || normalized.context_group.trim().length === 0) {
delete normalized.context_group;
delete normalized.continuity_mode;
} else {
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
if (normalizedGroup) {
normalized.context_group = normalizedGroup;
} else {
delete normalized.context_group;
}
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
}
return normalized;
@@ -68,8 +92,16 @@ export class ProfileRegistry {
if (normalized.context_mode !== 'shared') {
delete normalized.context_group;
} else if (!normalized.context_group || normalized.context_group.trim().length === 0) {
delete normalized.context_group;
delete normalized.continuity_mode;
} else {
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
if (normalizedGroup) {
normalized.context_group = normalizedGroup;
} else {
delete normalized.context_group;
}
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
}
return normalized;
@@ -140,6 +172,7 @@ export class ProfileRegistry {
last_used: metadata.last_used || null,
context_mode: metadata.context_mode,
context_group: metadata.context_group,
continuity_mode: metadata.continuity_mode,
});
// Note: No longer auto-set as default
@@ -159,7 +192,7 @@ export class ProfileRegistry {
throw new Error(`Profile not found: ${name}`);
}
return data.profiles[name];
return this.normalizeLegacyProfileMetadata(data.profiles[name]);
}
/**
@@ -215,7 +248,11 @@ export class ProfileRegistry {
*/
getAllProfiles(): Record<string, ProfileMetadata> {
const data = this._read();
return data.profiles;
const normalized: Record<string, ProfileMetadata> = {};
for (const [name, profile] of Object.entries(data.profiles)) {
normalized[name] = this.normalizeLegacyProfileMetadata(profile);
}
return normalized;
}
/**
@@ -283,6 +320,7 @@ export class ProfileRegistry {
last_used: null,
context_mode: metadata.context_mode,
context_group: metadata.context_group,
continuity_mode: metadata.continuity_mode,
});
saveUnifiedConfig(config);
}
@@ -357,7 +395,11 @@ export class ProfileRegistry {
getAllAccountsUnified(): Record<string, AccountConfig> {
if (!isUnifiedMode()) return {};
const config = loadOrCreateUnifiedConfig();
return config.accounts;
const normalized: Record<string, AccountConfig> = {};
for (const [name, account] of Object.entries(config.accounts)) {
normalized[name] = this.normalizeUnifiedAccountConfig(account);
}
return normalized;
}
/**
@@ -406,6 +448,7 @@ export class ProfileRegistry {
last_used: account.last_used,
context_mode: account.context_mode,
context_group: account.context_group,
continuity_mode: account.continuity_mode,
};
}
+213 -5
View File
@@ -12,7 +12,14 @@ import {
import { expandPath } from './utils/helpers';
import { validateGlmKey, validateMiniMaxKey } from './utils/api-key-validator';
import { ErrorManager } from './utils/error-manager';
import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy';
import {
execClaudeWithCLIProxy,
CLIProxyProvider,
ensureCliproxyService,
isAuthenticated,
} from './cliproxy';
import { getEffectiveEnvVars, getCompositeEnvVars } from './cliproxy/config/env-builder';
import { CLIPROXY_DEFAULT_PORT } from './cliproxy/config/port-manager';
import {
ensureMcpWebSearch,
displayWebSearchStatus,
@@ -47,9 +54,15 @@ import {
ClaudeAdapter,
DroidAdapter,
pruneOrphanedModels,
resolveDroidProvider,
type TargetCredentials,
} from './targets';
import { resolveTargetType, stripTargetFlag } from './targets/target-resolver';
import {
DroidReasoningFlagError,
resolveDroidReasoningRuntime,
} from './targets/droid-reasoning-runtime';
import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router';
// Version and Update check utilities
import { getVersion } from './utils/version';
@@ -694,13 +707,60 @@ async function main(): Promise<void> {
if (resolvedTarget === 'droid') {
try {
const allProfiles = detector.getAllProfiles();
const activeProfiles = allProfiles.settings.filter((name) => /^[a-zA-Z0-9_-]+$/.test(name));
const activeProfiles = allProfiles.settings.filter((name) =>
/^[a-zA-Z0-9._-]+$/.test(name)
);
await pruneOrphanedModels(activeProfiles);
} catch (error) {
console.error(warn(`[!] Droid prune skipped: ${(error as Error).message}`));
}
}
let targetRemainingArgs = remainingArgs;
let droidReasoningOverride: string | number | undefined;
if (resolvedTarget === 'droid') {
try {
const droidRoute = routeDroidCommandArgs(remainingArgs);
targetRemainingArgs = droidRoute.argsForDroid;
if (droidRoute.mode === 'interactive') {
const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING);
targetRemainingArgs = runtime.argsWithoutReasoningFlags;
droidReasoningOverride = runtime.reasoningOverride;
if (runtime.duplicateDisplays.length > 0) {
console.error(
warn(
`[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || '<first-flag>'}`
)
);
}
} else {
if (droidRoute.duplicateReasoningDisplays.length > 0) {
console.error(
warn(
`[!] Multiple reasoning flags detected. Using first occurrence: ${droidRoute.reasoningSourceDisplay || '<first-flag>'}`
)
);
}
if (droidRoute.autoPrependedExec && process.stdout.isTTY) {
console.error(
info('Detected Droid exec-only flags. Routing as: droid exec <flags> [prompt]')
);
}
}
} catch (error) {
if (error instanceof DroidReasoningFlagError || error instanceof DroidCommandRouterError) {
console.error(fail(error.message));
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
console.error(' Codex alias: --effort medium|high|xhigh');
console.error(' Droid exec: --reasoning-effort high');
process.exit(1);
}
throw error;
}
}
// Special case: headless delegation (-p/--prompt)
// Keep existing behavior for Claude targets only; non-claude targets must continue
// through normal adapter dispatch logic.
@@ -724,9 +784,145 @@ async function main(): Promise<void> {
const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider);
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
const variantPort = profileInfo.port; // variant-specific port for isolation
const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT;
if (resolvedTarget !== 'claude') {
const adapter = targetAdapter;
if (!adapter) {
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
process.exitCode = 1;
return;
}
if (!adapter.supportsProfileType('cliproxy')) {
console.error(fail(`${adapter.displayName} does not support CLIProxy profiles`));
process.exitCode = 1;
return;
}
// Keep CLIProxy management/auth flags on Claude flow only.
const unsupportedCliproxyFlags = [
'--auth',
'--logout',
'--accounts',
'--add',
'--use',
'--config',
'--headless',
'--paste-callback',
'--port-forward',
'--nickname',
'--kiro-auth-method',
'--backend',
'--proxy-host',
'--proxy-port',
'--proxy-protocol',
'--proxy-auth-token',
'--proxy-timeout',
'--local-proxy',
'--remote-only',
'--no-fallback',
'--allow-self-signed',
'--1m',
'--no-1m',
];
const providedUnsupportedFlag = unsupportedCliproxyFlags.find(
(flag) =>
targetRemainingArgs.includes(flag) ||
targetRemainingArgs.some((arg) => arg.startsWith(`${flag}=`))
);
if (providedUnsupportedFlag) {
console.error(
fail(
`${providedUnsupportedFlag} is only supported when running CLIProxy profiles on Claude target`
)
);
console.error(
info(`Run with Claude target: ccs ${profileInfo.name} --target claude ...`)
);
process.exitCode = 1;
return;
}
// For Droid execution path, require existing OAuth auth and running local proxy.
if (profileInfo.isComposite && profileInfo.compositeTiers) {
const compositeProviders = [
...new Set(Object.values(profileInfo.compositeTiers).map((tier) => tier.provider)),
] as CLIProxyProvider[];
const missingProvider = compositeProviders.find((p) => !isAuthenticated(p));
if (missingProvider) {
console.error(
fail(`Missing OAuth auth for composite tier provider: ${missingProvider}`)
);
console.error(info(`Authenticate first: ccs ${missingProvider} --auth`));
process.exitCode = 1;
return;
}
} else if (!isAuthenticated(provider)) {
console.error(fail(`No OAuth authentication found for provider: ${provider}`));
console.error(info(`Authenticate first: ccs ${provider} --auth`));
process.exitCode = 1;
return;
}
const ensureServiceResult = await ensureCliproxyService(
cliproxyPort,
targetRemainingArgs.includes('--verbose') || targetRemainingArgs.includes('-v')
);
if (!ensureServiceResult.started) {
console.error(
fail(ensureServiceResult.error || 'Failed to start local CLIProxy service')
);
process.exitCode = 1;
return;
}
const envVars =
profileInfo.isComposite && profileInfo.compositeTiers && profileInfo.compositeDefaultTier
? getCompositeEnvVars(
profileInfo.compositeTiers,
profileInfo.compositeDefaultTier,
cliproxyPort,
customSettingsPath
)
: getEffectiveEnvVars(provider, cliproxyPort, customSettingsPath);
const creds: TargetCredentials = {
profile: profileInfo.name,
baseUrl: envVars['ANTHROPIC_BASE_URL'] || '',
apiKey: envVars['ANTHROPIC_AUTH_TOKEN'] || '',
model: envVars['ANTHROPIC_MODEL'] || undefined,
provider: resolveDroidProvider({
provider: envVars['CCS_DROID_PROVIDER'] || envVars['DROID_PROVIDER'],
baseUrl: envVars['ANTHROPIC_BASE_URL'],
model: envVars['ANTHROPIC_MODEL'],
}),
reasoningOverride: droidReasoningOverride,
envVars,
};
if (!creds.baseUrl || !creds.apiKey) {
console.error(
fail(
`Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)`
)
);
console.error(
info('Reconfigure with: ccs config > CLIProxy, or run ccs <provider> --config')
);
process.exitCode = 1;
return;
}
await adapter.prepareCredentials(creds);
const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs);
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
return;
}
await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, {
customSettingsPath,
port: variantPort,
port: cliproxyPort,
isComposite: profileInfo.isComposite,
compositeTiers: profileInfo.compositeTiers,
compositeDefaultTier: profileInfo.compositeDefaultTier,
@@ -869,9 +1065,15 @@ async function main(): Promise<void> {
baseUrl: settingsEnv['ANTHROPIC_BASE_URL'] || '',
apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || '',
model: settingsEnv['ANTHROPIC_MODEL'],
provider: resolveDroidProvider({
provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'],
baseUrl: settingsEnv['ANTHROPIC_BASE_URL'],
model: settingsEnv['ANTHROPIC_MODEL'],
}),
reasoningOverride: droidReasoningOverride,
};
await adapter.prepareCredentials(creds);
const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs);
const targetArgs = adapter.buildArgs(profileInfo.name, targetRemainingArgs);
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
return;
@@ -935,6 +1137,12 @@ async function main(): Promise<void> {
baseUrl: process.env['ANTHROPIC_BASE_URL'] || '',
apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '',
model: process.env['ANTHROPIC_MODEL'],
provider: resolveDroidProvider({
provider: process.env['CCS_DROID_PROVIDER'] || process.env['DROID_PROVIDER'],
baseUrl: process.env['ANTHROPIC_BASE_URL'],
model: process.env['ANTHROPIC_MODEL'],
}),
reasoningOverride: droidReasoningOverride,
};
if (!creds.baseUrl || !creds.apiKey) {
console.error(
@@ -946,7 +1154,7 @@ async function main(): Promise<void> {
process.exit(1);
}
await adapter.prepareCredentials(creds);
const targetArgs = adapter.buildArgs('default', remainingArgs);
const targetArgs = adapter.buildArgs('default', targetRemainingArgs);
const targetEnv = adapter.buildEnv(creds, 'default');
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
return;
+6
View File
@@ -234,11 +234,17 @@ export function warnOAuthBanRisk(provider: CLIProxyProvider): void {
if (!isBanWarningProvider(provider) || shownBanWarnings.has(provider)) return;
shownBanWarnings.add(provider);
const isAgy = provider === 'agy';
console.error('');
console.error(warn('Account safety warning (#509 - read before continuing)'));
console.error(
' Known risk: one Google account shared by "ccs gemini" + "ccs agy" can be disabled/banned.'
);
if (isAgy) {
console.error(
' Antigravity-specific warning: OAuth usage can still trigger suspension/ban patterns.'
);
}
console.error(
' This risk applies whether auth was done from CLI or from "ccs config" dashboard.'
);
+225
View File
@@ -0,0 +1,225 @@
/**
* Antigravity OAuth Responsibility Gate
*
* Enforces explicit user acknowledgement for Antigravity OAuth usage.
* This is used by:
* - CLI OAuth flow (`ccs agy --auth`)
* - CLI runtime flow (`ccs agy`)
* - Dashboard auth endpoints (server-side payload validation)
*/
import { createInterface, Interface } from 'readline';
import { fail, info, ok, warn } from '../utils/ui';
import { getCliproxySafetyConfig } from '../config/unified-config-loader';
export const ANTIGRAVITY_RISK_ISSUE_URL = 'https://github.com/kaitranntt/ccs/issues/509';
export const ANTIGRAVITY_ACK_VERSION = '2026-02-24-antigravity-oauth-v2';
export const RISK_ACK_PHRASE = 'I ACCEPT RISK';
export const ANTIGRAVITY_ACK_PHRASE = RISK_ACK_PHRASE;
export const ANTIGRAVITY_ACCEPT_RISK_FLAGS = ['--accept-agr-risk', '--accept-antigravity-risk'];
type AgyRiskContext = 'oauth' | 'run';
export interface AntigravityRiskAcknowledgement {
version: string;
reviewedIssue509: boolean;
understandsBanRisk: boolean;
acceptsFullResponsibility: boolean;
typedPhrase: string;
}
interface ValidationResult {
valid: boolean;
error?: string;
}
interface EnsureCliRiskOptions {
context: AgyRiskContext;
acceptedByFlag?: boolean;
}
function normalizePhrase(value: string): string {
return value.trim().replace(/\s+/g, ' ').toUpperCase();
}
function isTruthyEnv(value: string | undefined): boolean {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
export function isAntigravityResponsibilityBypassEnabled(): boolean {
if (isTruthyEnv(process.env.CCS_ACCEPT_AGY_RISK)) {
return true;
}
try {
const safety = getCliproxySafetyConfig();
return safety.antigravity_ack_bypass === true;
} catch {
return false;
}
}
function askQuestion(rl: Interface, prompt: string): Promise<string | null> {
return new Promise((resolve) => {
let settled = false;
const onClose = () => {
if (!settled) {
settled = true;
resolve(null);
}
};
rl.once('close', onClose);
rl.question(prompt, (answer) => {
if (settled) return;
settled = true;
rl.removeListener('close', onClose);
resolve(answer.trim());
});
});
}
async function askYesNoStep(rl: Interface, step: string, message: string): Promise<boolean> {
while (true) {
const answer = await askQuestion(
rl,
`[?] ${step}\n ${message}\n Type YES to continue (NO to cancel): `
);
if (answer === null) return false;
const normalized = answer.toUpperCase();
if (normalized === 'YES') return true;
if (normalized === 'NO' || normalized === 'N' || normalized === '') return false;
console.error(warn('Please type YES or NO.'));
}
}
async function askResponsibilityPhrase(rl: Interface): Promise<boolean> {
for (let attempt = 0; attempt < 3; attempt++) {
const answer = await askQuestion(
rl,
`[?] Step 4/4\n Type exactly "${ANTIGRAVITY_ACK_PHRASE}": `
);
if (answer === null || answer === '') return false;
if (normalizePhrase(answer) === ANTIGRAVITY_ACK_PHRASE) {
return true;
}
console.error(warn('Phrase mismatch. Try again.'));
}
return false;
}
function printResponsibilityHeader(context: AgyRiskContext): void {
const contextLine =
context === 'oauth'
? 'You are starting Antigravity OAuth account authorization.'
: 'You are starting a live Antigravity CLI session (ccs agy).';
console.error('');
console.error('╔══════════════════════════════════════════════════════════════════════╗');
console.error('║ Antigravity Responsibility Confirmation (Mandatory) ║');
console.error('╚══════════════════════════════════════════════════════════════════════╝');
console.error(` ${contextLine}`);
console.error(' Antigravity has active ban/suspension patterns for risky OAuth usage.');
console.error(` Policy issue: ${ANTIGRAVITY_RISK_ISSUE_URL}`);
console.error('');
}
export function hasAntigravityRiskAcceptanceFlag(args: string[]): boolean {
return args.some((arg) => ANTIGRAVITY_ACCEPT_RISK_FLAGS.includes(arg));
}
export function validateAntigravityRiskAcknowledgement(payload: unknown): ValidationResult {
if (!payload || typeof payload !== 'object') {
return {
valid: false,
error: 'Antigravity OAuth requires a full responsibility acknowledgement payload.',
};
}
const data = payload as Partial<AntigravityRiskAcknowledgement>;
if (data.version !== ANTIGRAVITY_ACK_VERSION) {
return {
valid: false,
error: 'Antigravity acknowledgement version mismatch. Re-open add account and try again.',
};
}
if (!data.reviewedIssue509 || !data.understandsBanRisk || !data.acceptsFullResponsibility) {
return {
valid: false,
error: 'Complete all Antigravity responsibility checklist steps before authenticating.',
};
}
if (
typeof data.typedPhrase !== 'string' ||
normalizePhrase(data.typedPhrase) !== ANTIGRAVITY_ACK_PHRASE
) {
return {
valid: false,
error: `Type exact acknowledgement phrase: "${ANTIGRAVITY_ACK_PHRASE}".`,
};
}
return { valid: true };
}
export async function ensureCliAntigravityResponsibility(
options: EnsureCliRiskOptions
): Promise<boolean> {
if (options.acceptedByFlag || isAntigravityResponsibilityBypassEnabled()) {
return true;
}
if (!process.stdin.isTTY || !process.stderr.isTTY) {
console.error(fail('Antigravity responsibility acknowledgement required.'));
console.error(' Re-run interactively and complete the 4-step confirmation.');
console.error(' Non-interactive override: --accept-agr-risk');
return false;
}
printResponsibilityHeader(options.context);
const rl = createInterface({
input: process.stdin,
output: process.stderr,
});
try {
const step1 = await askYesNoStep(
rl,
'Step 1/4',
'I reviewed issue #509 and understand AGY OAuth can trigger bans/suspensions.'
);
if (!step1) return false;
const step2 = await askYesNoStep(
rl,
'Step 2/4',
'I understand this OAuth operation is my own decision and I choose to continue.'
);
if (!step2) return false;
const step3 = await askYesNoStep(
rl,
'Step 3/4',
'I accept that CCS provides no responsibility coverage for account loss, bans, or suspension.'
);
if (!step3) return false;
const step4 = await askResponsibilityPhrase(rl);
if (!step4) return false;
console.error(ok('Antigravity responsibility acknowledgement accepted for this command.'));
console.error(info('Proceeding with Antigravity flow...'));
return true;
} finally {
rl.close();
}
}
+2
View File
@@ -273,6 +273,8 @@ export interface OAuthOptions {
account?: string;
add?: boolean;
nickname?: string;
/** If true, caller explicitly accepts Antigravity OAuth risk for this command/session. */
acceptAgyRisk?: boolean;
/** Kiro auth method override (CLI + Dashboard parity). */
kiroMethod?: KiroAuthMethod;
/** If true, triggered from Web UI (enables project selection prompt) */
+20
View File
@@ -50,6 +50,7 @@ import {
warnOAuthBanRisk,
warnPossible403Ban,
} from '../account-safety';
import { ensureCliAntigravityResponsibility } from '../antigravity-responsibility';
/**
* Prompt user to add another account
@@ -429,10 +430,29 @@ export async function triggerOAuth(
const oauthConfig = getOAuthConfig(provider);
warnOAuthBanRisk(provider);
const { verbose = false, add = false, fromUI = false, noIncognito = true } = options;
const acceptAgyRisk = options.acceptAgyRisk === true;
let { nickname } = options;
const resolvedKiroMethod =
provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD;
if (provider === 'agy') {
if (fromUI && !acceptAgyRisk) {
console.log(fail('Antigravity OAuth blocked: responsibility acknowledgement is missing.'));
return null;
}
if (!fromUI) {
const acknowledged = await ensureCliAntigravityResponsibility({
context: 'oauth',
acceptedByFlag: acceptAgyRisk,
});
if (!acknowledged) {
console.log(info('Cancelled'));
return null;
}
}
}
// Check for existing accounts
const existingAccounts = getProviderAccounts(provider);
+63
View File
@@ -37,6 +37,8 @@ const DEPRECATED_MODEL_PREFIX = 'gemini-claude-';
/** Replacement prefix matching actual upstream model names */
const UPSTREAM_MODEL_PREFIX = 'claude-';
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
const IFLOW_PLACEHOLDER_MODEL = 'iflow-default';
const IFLOW_DEFAULT_MODEL = 'qwen3-coder-plus';
const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const;
const REQUIRED_PROVIDER_ENV_KEYS = [
'ANTHROPIC_BASE_URL',
@@ -136,6 +138,61 @@ function migrateCodexEffortSuffixes(
return migrated;
}
/**
* Migrate legacy iFlow placeholder model IDs to a real default model.
* Example: iflow-default -> qwen3-coder-plus
*/
function migrateIFlowPlaceholderModel(
settingsPath: string,
provider: CLIProxyProvider,
settings: ProviderSettings
): boolean {
if (provider !== 'iflow') return false;
if (!settings.env || typeof settings.env !== 'object') return false;
let migrated = false;
const normalize = (value: string): string => value.trim().toLowerCase();
const replaceIfPlaceholder = (value: string): string =>
normalize(value) === IFLOW_PLACEHOLDER_MODEL ? IFLOW_DEFAULT_MODEL : value;
for (const key of MODEL_ENV_VAR_KEYS) {
const value = settings.env[key];
if (typeof value !== 'string') continue;
const replaced = replaceIfPlaceholder(value);
if (replaced !== value) {
settings.env[key] = replaced;
migrated = true;
}
}
if (Array.isArray(settings.presets)) {
for (const preset of settings.presets) {
if (!preset || typeof preset !== 'object') continue;
const presetRecord = preset as Record<string, unknown>;
for (const key of PRESET_MODEL_KEYS) {
const value = presetRecord[key];
if (typeof value !== 'string') continue;
const replaced = replaceIfPlaceholder(value);
if (replaced !== value) {
presetRecord[key] = replaced;
migrated = true;
}
}
}
}
if (migrated) {
try {
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
} catch {
// Best-effort migration — don't block startup if write fails
}
}
return migrated;
}
/** Remote proxy configuration for URL rewriting */
export interface RemoteProxyRewriteConfig {
host: string;
@@ -355,6 +412,8 @@ export function getEffectiveEnvVars(
migrateDeprecatedModelNames(expandedPath, settings);
// Migrate codex effort suffixes to canonical IDs if present
migrateCodexEffortSuffixes(expandedPath, provider, settings);
// Migrate legacy iFlow placeholders to supported model IDs
migrateIFlowPlaceholderModel(expandedPath, provider, settings);
// Custom variant settings found - merge with global env
envVars = { ...globalEnv, ...settings.env };
// Ensure required vars are present (fall back to defaults if missing)
@@ -388,6 +447,8 @@ export function getEffectiveEnvVars(
migrateDeprecatedModelNames(settingsPath, settings);
// Migrate codex effort suffixes to canonical IDs if present
migrateCodexEffortSuffixes(settingsPath, provider, settings);
// Migrate legacy iFlow placeholders to supported model IDs
migrateIFlowPlaceholderModel(settingsPath, provider, settings);
// User override found - merge with global env
envVars = { ...globalEnv, ...settings.env };
// Ensure required vars are present (fall back to defaults if missing)
@@ -525,6 +586,7 @@ export function getRemoteEnvVars(
if (settings.env && typeof settings.env === 'object') {
migrateDeprecatedModelNames(expandedPath, settings);
migrateCodexEffortSuffixes(expandedPath, provider, settings);
migrateIFlowPlaceholderModel(expandedPath, provider, settings);
userEnvVars = settings.env as Record<string, string>;
}
} catch {
@@ -544,6 +606,7 @@ export function getRemoteEnvVars(
if (settings.env && typeof settings.env === 'object') {
migrateDeprecatedModelNames(settingsPath, settings);
migrateCodexEffortSuffixes(settingsPath, provider, settings);
migrateIFlowPlaceholderModel(settingsPath, provider, settings);
userEnvVars = settings.env as Record<string, string>;
}
} catch {
+21 -8
View File
@@ -31,8 +31,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs';
* v7: Added fork:true for Claude model aliases (keep both upstream and alias names)
* v8: Added Gemini 3.1 preview aliases for provider routing compatibility
* v9: Added resilient alias compatibility expansion and cache-assisted alias enrichment
* v10: Migrated deprecated gemini-claude-* aliases to upstream claude-* aliases
*/
export const CLIPROXY_CONFIG_VERSION = 9;
export const CLIPROXY_CONFIG_VERSION = 10;
interface OAuthModelAliasEntry {
name: string;
@@ -41,6 +42,8 @@ interface OAuthModelAliasEntry {
}
const GEMINI_MINOR_COMPAT_RANGE = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const;
const DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX = 'gemini-claude-';
const UPSTREAM_CLAUDE_ALIAS_PREFIX = 'claude-';
/**
* Default Antigravity oauth-model-alias entries.
@@ -54,12 +57,12 @@ const DEFAULT_ANTIGRAVITY_ALIASES: OAuthModelAliasEntry[] = [
{ name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview' },
{ name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview-customtools' },
{ name: 'gemini-3-flash', alias: 'gemini-3-flash-preview' },
{ name: 'claude-sonnet-4-6', alias: 'gemini-claude-sonnet-4-6', fork: true },
{ name: 'claude-sonnet-4-6-thinking', alias: 'gemini-claude-sonnet-4-6-thinking', fork: true },
{ name: 'claude-sonnet-4-5', alias: 'gemini-claude-sonnet-4-5', fork: true },
{ name: 'claude-sonnet-4-5-thinking', alias: 'gemini-claude-sonnet-4-5-thinking', fork: true },
{ name: 'claude-opus-4-5-thinking', alias: 'gemini-claude-opus-4-5-thinking', fork: true },
{ name: 'claude-opus-4-6-thinking', alias: 'gemini-claude-opus-4-6-thinking', fork: true },
{ name: 'claude-sonnet-4-6', alias: 'claude-sonnet-4-6', fork: true },
{ name: 'claude-sonnet-4-6-thinking', alias: 'claude-sonnet-4-6-thinking', fork: true },
{ name: 'claude-sonnet-4-5', alias: 'claude-sonnet-4-5', fork: true },
{ name: 'claude-sonnet-4-5-thinking', alias: 'claude-sonnet-4-5-thinking', fork: true },
{ name: 'claude-opus-4-5-thinking', alias: 'claude-opus-4-5-thinking', fork: true },
{ name: 'claude-opus-4-6-thinking', alias: 'claude-opus-4-6-thinking', fork: true },
];
/**
@@ -101,6 +104,16 @@ function sanitizeYamlScalar(rawValue: string): string {
return trimmed;
}
function normalizeAntigravityAlias(rawAlias: string): string {
const normalized = sanitizeYamlScalar(rawAlias);
if (normalized.toLowerCase().startsWith(DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX)) {
return (
UPSTREAM_CLAUDE_ALIAS_PREFIX + normalized.slice(DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX.length)
);
}
return normalized;
}
function addAliasEntry(
entries: OAuthModelAliasEntry[],
indexByKey: Map<string, number>,
@@ -108,7 +121,7 @@ function addAliasEntry(
): void {
const normalized: OAuthModelAliasEntry = {
name: sanitizeYamlScalar(entry.name),
alias: sanitizeYamlScalar(entry.alias),
alias: normalizeAntigravityAlias(entry.alias),
fork: entry.fork || undefined,
};
if (!normalized.name || !normalized.alias) return;
+42
View File
@@ -72,6 +72,11 @@ import {
enforceProviderIsolation,
restoreAutoPausedAccounts,
} from '../account-safety';
import {
ensureCliAntigravityResponsibility,
hasAntigravityRiskAcceptanceFlag,
ANTIGRAVITY_ACCEPT_RISK_FLAGS,
} from '../antigravity-responsibility';
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
import {
buildThinkingStartupStatus,
@@ -286,6 +291,7 @@ export async function execClaudeWithCLIProxy(
const addAccount = argsWithoutProxy.includes('--add');
const showAccounts = argsWithoutProxy.includes('--accounts');
const forceImport = argsWithoutProxy.includes('--import');
const acceptAgyRisk = hasAntigravityRiskAcceptanceFlag(argsWithoutProxy);
const incognitoFlag = argsWithoutProxy.includes('--incognito');
const noIncognitoFlag = argsWithoutProxy.includes('--no-incognito');
@@ -523,6 +529,38 @@ export async function execClaudeWithCLIProxy(
log(`Using remote proxy authentication (skipping local OAuth)`);
}
if (provider === 'agy' && forceAuth && skipLocalAuth) {
const acknowledged = await ensureCliAntigravityResponsibility({
context: 'oauth',
acceptedByFlag: acceptAgyRisk,
});
if (!acknowledged) {
throw new Error(
`Antigravity auth blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.`
);
}
console.error(info('Remote proxy mode is active; local OAuth flow is skipped in --auth mode.'));
return;
}
if (provider === 'agy' && !forceAuth) {
const requiresAuthNow = providerConfig.requiresOAuth && !isAuthenticated(provider);
if (skipLocalAuth || !requiresAuthNow) {
const acknowledged = await ensureCliAntigravityResponsibility({
context: 'run',
acceptedByFlag: acceptAgyRisk,
});
if (!acknowledged) {
console.error(
fail(
`Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.`
)
);
process.exit(1);
}
}
}
if (providerConfig.requiresOAuth && !skipLocalAuth) {
log(`Checking authentication for ${provider}`);
@@ -536,6 +574,7 @@ export async function execClaudeWithCLIProxy(
const authSuccess = await triggerOAuth(p, {
verbose,
add: addAccount,
...(acceptAgyRisk ? { acceptAgyRisk: true } : {}),
...(kiroAuthMethod && p === 'kiro' ? { kiroMethod: kiroAuthMethod } : {}),
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
@@ -577,6 +616,7 @@ export async function execClaudeWithCLIProxy(
const authSuccess = await triggerOAuth(provider, {
verbose,
add: addAccount,
...(acceptAgyRisk ? { acceptAgyRisk: true } : {}),
...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}),
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
@@ -922,6 +962,8 @@ export async function execClaudeWithCLIProxy(
'--incognito',
'--no-incognito',
'--import',
'--accept-agr-risk',
'--accept-antigravity-risk',
'--settings',
...PROXY_CLI_FLAGS,
];
+29 -40
View File
@@ -1,12 +1,7 @@
/**
* CLIProxy Variant Config Adapters
*
* Handles reading/writing variant config in both unified and legacy formats.
*/
import * as fs from 'fs';
import { getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { CLIProxyProvider } from '../types';
import type { TargetType } from '../../targets/target-adapter';
import {
CLIProxyVariantConfig,
CompositeVariantConfig,
@@ -20,19 +15,15 @@ import {
} from '../../config/unified-config-loader';
import { CLIPROXY_DEFAULT_PORT } from '../config-generator';
/** First port for variant profiles (8318 = default + 1) */
export const VARIANT_PORT_BASE = CLIPROXY_DEFAULT_PORT + 1;
/** Maximum port offset for variants (100 ports: 8318-8417) */
export const VARIANT_PORT_MAX_OFFSET = 100;
/** Variant configuration structure */
export interface VariantConfig {
provider: string;
settings?: string;
account?: string;
model?: string;
port?: number;
target?: TargetType;
/** Composite variant fields */
type?: 'composite';
default_tier?: 'opus' | 'sonnet' | 'haiku';
@@ -45,9 +36,6 @@ export interface VariantConfig {
hasFallback?: boolean;
}
/**
* Check if variant exists in config
*/
export function variantExistsInConfig(name: string): boolean {
try {
if (isUnifiedMode()) {
@@ -61,10 +49,6 @@ export function variantExistsInConfig(name: string): boolean {
}
}
/**
* Get next available port for a new variant.
* Scans existing variants, returns first unused port starting from VARIANT_PORT_BASE.
*/
export function getNextAvailablePort(): number {
const variants = listVariantsFromConfig();
const usedPorts = new Set<number>();
@@ -89,9 +73,6 @@ export function getNextAvailablePort(): number {
);
}
/**
* List variants from config
*/
export function listVariantsFromConfig(): Record<string, VariantConfig> {
try {
if (isUnifiedMode()) {
@@ -139,6 +120,7 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
provider: defaultTierConfig.provider,
settings: composite.settings,
port: composite.port,
target: composite.target || 'claude',
type: 'composite',
default_tier: composite.default_tier,
tiers: normalizedTiers,
@@ -151,6 +133,7 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
settings: single.settings,
account: single.account,
port: single.port,
target: single.target || 'claude',
};
}
} catch (error) {
@@ -171,12 +154,14 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
settings: string;
account?: string;
port?: number;
target?: TargetType;
};
result[name] = {
provider: v.provider,
settings: v.settings,
account: v.account,
port: v.port,
target: v.target || 'claude',
};
}
return result;
@@ -185,9 +170,6 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
}
}
/**
* Save composite variant to unified config
*/
export function saveCompositeVariantUnified(name: string, config: CompositeVariantConfig): void {
const unifiedConfig = loadOrCreateUnifiedConfig();
@@ -206,15 +188,13 @@ export function saveCompositeVariantUnified(name: string, config: CompositeVaria
saveUnifiedConfig(unifiedConfig);
}
/**
* Save variant to unified config
*/
export function saveVariantUnified(
name: string,
provider: CLIProxyProvider,
settingsPath: string,
account?: string,
port?: number
port?: number,
target: TargetType = 'claude'
): void {
const config = loadOrCreateUnifiedConfig();
@@ -234,20 +214,19 @@ export function saveVariantUnified(
account,
settings: settingsPath,
port,
...(target !== 'claude' && { target }),
};
saveUnifiedConfig(config);
}
/**
* Save variant to legacy JSON config
*/
export function saveVariantLegacy(
name: string,
provider: string,
settingsPath: string,
account?: string,
port?: number
port?: number,
target: TargetType = 'claude'
): void {
const configPath = getConfigPath();
@@ -262,7 +241,13 @@ export function saveVariantLegacy(
config.cliproxy = {};
}
const variantConfig: { provider: string; settings: string; account?: string; port?: number } = {
const variantConfig: {
provider: string;
settings: string;
account?: string;
port?: number;
target?: TargetType;
} = {
provider,
settings: settingsPath,
};
@@ -272,6 +257,9 @@ export function saveVariantLegacy(
if (port) {
variantConfig.port = port;
}
if (target !== 'claude') {
variantConfig.target = target;
}
config.cliproxy[name] = variantConfig;
const tempPath = configPath + '.tmp';
@@ -279,9 +267,6 @@ export function saveVariantLegacy(
fs.renameSync(tempPath, configPath);
}
/**
* Remove variant from unified config
*/
export function removeVariantFromUnifiedConfig(name: string): VariantConfig | null {
const config = loadOrCreateUnifiedConfig();
@@ -299,6 +284,7 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu
provider: composite.tiers[composite.default_tier].provider,
settings: composite.settings,
port: composite.port,
target: composite.target || 'claude',
type: 'composite',
default_tier: composite.default_tier,
tiers: composite.tiers,
@@ -309,12 +295,10 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu
provider: singleVariant.provider,
settings: singleVariant.settings,
port: singleVariant.port,
target: singleVariant.target || 'claude',
};
}
/**
* Remove variant from legacy JSON config
*/
export function removeVariantFromLegacyConfig(name: string): VariantConfig | null {
const configPath = getConfigPath();
@@ -329,7 +313,12 @@ export function removeVariantFromLegacyConfig(name: string): VariantConfig | nul
return null;
}
const variant = config.cliproxy[name] as { provider: string; settings: string; port?: number };
const variant = config.cliproxy[name] as {
provider: string;
settings: string;
port?: number;
target?: TargetType;
};
delete config.cliproxy[name];
if (Object.keys(config.cliproxy).length === 0) {
+34 -9
View File
@@ -10,6 +10,7 @@ import * as path from 'path';
import { CLIProxyProfileName } from '../../auth/profile-detector';
import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS } from '../types';
import { CompositeTierConfig, CompositeVariantConfig } from '../../config/unified-config-types';
import type { TargetType } from '../../targets/target-adapter';
import { isReservedName, isWindowsReservedName } from '../../config/reserved-names';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { DEFAULT_BACKEND } from '../platform-detector';
@@ -110,7 +111,8 @@ export function createVariant(
name: string,
provider: CLIProxyProfileName,
model: string,
account?: string
account?: string,
target: TargetType = 'claude'
): VariantOperationResult {
try {
// Validate provider/backend compatibility (block kiro/ghcp on original backend)
@@ -131,17 +133,25 @@ export function createVariant(
provider as CLIProxyProvider,
getRelativeSettingsPath(provider, name),
account,
port
port,
target
);
} else {
settingsPath = createSettingsFile(name, provider, model, port);
saveVariantLegacy(name, provider, `~/.ccs/${path.basename(settingsPath)}`, account, port);
saveVariantLegacy(
name,
provider,
`~/.ccs/${path.basename(settingsPath)}`,
account,
port,
target
);
}
return {
success: true,
settingsPath,
variant: { provider, model, account, port },
variant: { provider, model, account, port, target },
};
} catch (error) {
return {
@@ -208,6 +218,7 @@ export interface UpdateVariantOptions {
provider?: CLIProxyProfileName;
account?: string;
model?: string;
target?: TargetType;
}
/**
@@ -233,6 +244,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
const providerChanged =
updates.provider !== undefined && updates.provider !== existing.provider;
const existingTarget = existing.target || 'claude';
const targetChanged = updates.target !== undefined && updates.target !== existingTarget;
const hasModelUpdate = updates.model !== undefined && updates.model.trim().length > 0;
if (providerChanged && !hasModelUpdate) {
@@ -257,8 +270,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
}
}
// Update config entry if provider or account changed
if (updates.provider !== undefined || updates.account !== undefined) {
// Update config entry if provider/account/target changed
if (updates.provider !== undefined || updates.account !== undefined || targetChanged) {
const newProvider = updates.provider ?? existing.provider;
// Validate provider/backend compatibility on provider change
@@ -269,6 +282,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
}
}
const newAccount = updates.account !== undefined ? updates.account : existing.account;
const newTarget = updates.target ?? existingTarget;
if (isUnifiedMode()) {
saveVariantUnified(
@@ -276,7 +290,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
newProvider as CLIProxyProvider,
existing.settings || '',
newAccount || undefined,
existing.port
existing.port,
newTarget
);
} else {
saveVariantLegacy(
@@ -284,7 +299,8 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
newProvider,
existing.settings || '',
newAccount || undefined,
existing.port
existing.port,
newTarget
);
}
}
@@ -297,6 +313,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
account: updates.account !== undefined ? updates.account : existing.account,
port: existing.port,
settings: existing.settings,
target: updates.target ?? existingTarget,
},
};
} catch (error) {
@@ -308,6 +325,7 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
export interface CreateCompositeVariantOptions {
name: string;
defaultTier: 'opus' | 'sonnet' | 'haiku';
target?: TargetType;
tiers: {
opus: CompositeTierConfig;
sonnet: CompositeTierConfig;
@@ -329,7 +347,7 @@ export function createCompositeVariant(
}
try {
const { name, defaultTier, tiers } = options;
const { name, defaultTier, tiers, target = 'claude' } = options;
const validationError = validateCompositeTiers(tiers, {
defaultTier,
@@ -361,6 +379,7 @@ export function createCompositeVariant(
tiers,
settings: settingsPath,
port,
...(target !== 'claude' && { target }),
};
saveCompositeVariantUnified(name, compositeConfig);
@@ -373,6 +392,7 @@ export function createCompositeVariant(
default_tier: defaultTier,
tiers,
port,
target,
},
};
} catch (error) {
@@ -384,6 +404,7 @@ export function createCompositeVariant(
export interface UpdateCompositeVariantOptions {
defaultTier?: 'opus' | 'sonnet' | 'haiku';
tiers?: Partial<Record<'opus' | 'sonnet' | 'haiku', CompositeTierConfig>>;
target?: TargetType;
}
/**
@@ -418,6 +439,8 @@ export function updateCompositeVariant(
};
const newDefaultTier = updates.defaultTier ?? existing.default_tier ?? 'sonnet';
const existingTarget = existing.target || 'claude';
const newTarget = updates.target ?? existingTarget;
const validationError = validateCompositeTiers(mergedTiers, {
defaultTier: newDefaultTier,
requireAllTiers: true,
@@ -455,6 +478,7 @@ export function updateCompositeVariant(
tiers: mergedTiers,
settings: settingsRef,
port: existing.port,
...(newTarget !== 'claude' && { target: newTarget }),
};
saveCompositeVariantUnified(name, compositeConfig);
@@ -468,6 +492,7 @@ export function updateCompositeVariant(
tiers: mergedTiers,
port: existing.port,
settings: settingsRef,
target: newTarget,
},
};
} catch (error) {
+17 -2
View File
@@ -7,6 +7,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers';
import { listApiProfiles, isApiProfileConfigured } from '../../api/services/profile-reader';
import type { ClaudeKey } from '../management-api-types';
@@ -31,6 +32,15 @@ interface SettingsJson {
env?: Record<string, string>;
}
function resolveProfileSettingsPath(settingsPath: string): string {
const normalized = settingsPath.replace(/\\/g, '/');
if (normalized.startsWith('~/.ccs/')) {
return path.join(getCcsDir(), normalized.slice('~/.ccs/'.length));
}
return expandPath(settingsPath);
}
/**
* Load syncable API profiles from CCS config.
* Filters to only configured profiles (with real API keys).
@@ -45,9 +55,14 @@ export function loadSyncableProfiles(): SyncableProfile[] {
continue;
}
// Local CLIProxy sync writes Claude-compatible entries only.
// Profiles pinned to non-claude targets are intentionally skipped.
if (profile.target !== 'claude') {
continue;
}
// Load settings.json for env vars
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile.name}.settings.json`);
const settingsPath = resolveProfileSettingsPath(profile.settingsPath);
let env: Record<string, string> | undefined;
try {
+69 -10
View File
@@ -43,6 +43,7 @@ import {
type ProviderPreset,
} from '../api/services';
import { syncToLocalConfig } from '../cliproxy/sync/local-config-sync';
import type { TargetType } from '../targets/target-adapter';
import { extractOption, hasAnyFlag } from './arg-extractor';
interface ApiCommandArgs {
@@ -51,13 +52,14 @@ interface ApiCommandArgs {
apiKey?: string;
model?: string;
preset?: string;
target?: TargetType;
force?: boolean;
yes?: boolean;
errors: string[];
}
const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y'] as const;
const API_VALUE_FLAGS = ['--base-url', '--api-key', '--model', '--preset'] as const;
const API_VALUE_FLAGS = ['--base-url', '--api-key', '--model', '--preset', '--target'] as const;
const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS];
const API_VALUE_FLAG_SET = new Set<string>(API_VALUE_FLAGS);
@@ -130,6 +132,14 @@ function extractPositionalArgs(args: string[]): string[] {
return positionals;
}
function parseTargetValue(value: string): TargetType | null {
const normalized = value.trim().toLowerCase();
if (normalized === 'claude' || normalized === 'droid') {
return normalized;
}
return null;
}
/** Parse command line arguments for api commands */
export function parseApiCommandArgs(args: string[]): ApiCommandArgs {
const result: ApiCommandArgs = {
@@ -184,6 +194,22 @@ export function parseApiCommandArgs(args: string[]): ApiCommandArgs {
}
);
remaining = applyRepeatedOption(
remaining,
['--target'],
(value) => {
const target = parseTargetValue(value);
if (!target) {
result.errors.push(`Invalid --target value "${value}". Use: claude or droid`);
return;
}
result.target = target;
},
() => {
result.errors.push('Missing value for --target');
}
);
const positionalArgs = extractPositionalArgs(remaining);
result.name = positionalArgs[0];
return result;
@@ -383,12 +409,23 @@ async function handleCreate(args: string[]): Promise<void> {
sonnet: sonnetModel,
haiku: haikuModel,
};
let resolvedTarget: TargetType = parsedArgs.target || 'claude';
if (!parsedArgs.target && !parsedArgs.yes) {
const useDroidByDefault = await InteractivePrompt.confirm(
'Set default target to Factory Droid for this profile?',
{ default: false }
);
if (useDroidByDefault) {
resolvedTarget = 'droid';
}
}
// Create profile
console.log('');
console.log(info('Creating API profile...'));
const result = createApiProfile(name, baseUrl, apiKey, models);
const result = createApiProfile(name, baseUrl, apiKey, models, resolvedTarget);
if (!result.success) {
console.log(fail(`Failed to create API profile: ${result.error}`));
@@ -411,7 +448,8 @@ async function handleCreate(args: string[]): Promise<void> {
`Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` +
`Settings: ${result.settingsFile}\n` +
`Base URL: ${baseUrl}\n` +
`Model: ${model}`;
`Model: ${model}\n` +
`Target: ${resolvedTarget}`;
if (hasCustomMapping) {
infoMsg +=
@@ -424,7 +462,24 @@ async function handleCreate(args: string[]): Promise<void> {
console.log(infoBox(infoMsg, 'API Profile Created'));
console.log('');
console.log(header('Usage'));
console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`);
if (resolvedTarget === 'droid') {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}`
);
console.log(
` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}`
);
console.log(
` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}`
);
} else {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}`
);
console.log(
` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}`
);
}
console.log('');
console.log(header('Edit Settings'));
console.log(` ${dim('To modify env vars later:')}`);
@@ -453,13 +508,13 @@ async function handleList(): Promise<void> {
// Build table data
const rows: string[][] = profiles.map((p) => {
const status = p.isConfigured ? color('[OK]', 'success') : color('[!]', 'warning');
return [p.name, p.settingsPath, status];
return [p.name, p.target, p.settingsPath, status];
});
const colWidths = isUsingUnifiedConfig() ? [15, 20, 10] : [15, 35, 10];
const colWidths = isUsingUnifiedConfig() ? [15, 10, 20, 10] : [15, 10, 35, 10];
console.log(
table(rows, {
head: ['API', isUsingUnifiedConfig() ? 'Config' : 'Settings File', 'Status'],
head: ['API', 'Target', isUsingUnifiedConfig() ? 'Config' : 'Settings File', 'Status'],
colWidths,
})
);
@@ -468,11 +523,11 @@ async function handleList(): Promise<void> {
// Show CLIProxy variants if any
if (variants.length > 0) {
console.log(subheader('CLIProxy Variants'));
const cliproxyRows = variants.map((v) => [v.name, v.provider, v.settings]);
const cliproxyRows = variants.map((v) => [v.name, v.provider, v.target, v.settings]);
console.log(
table(cliproxyRows, {
head: ['Variant', 'Provider', 'Settings'],
colWidths: [15, 15, 30],
head: ['Variant', 'Provider', 'Target', 'Settings'],
colWidths: [15, 12, 10, 28],
})
);
console.log('');
@@ -582,6 +637,9 @@ async function showHelp(): Promise<void> {
console.log(` ${color('--base-url <url>', 'command')} API base URL (create)`);
console.log(` ${color('--api-key <key>', 'command')} API key (create)`);
console.log(` ${color('--model <model>', 'command')} Default model (create)`);
console.log(
` ${color('--target <cli>', 'command')} Default target: claude or droid (create)`
);
console.log(` ${color('--force', 'command')} Overwrite existing (create)`);
console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`);
console.log('');
@@ -605,6 +663,7 @@ async function showHelp(): Promise<void> {
console.log('');
console.log(` ${dim('# Create with name')}`);
console.log(` ${color('ccs api create myapi', 'command')}`);
console.log(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`);
console.log('');
console.log(` ${dim('# Remove API profile')}`);
console.log(` ${color('ccs api remove myapi', 'command')}`);
+5 -2
View File
@@ -45,10 +45,13 @@ export async function handleList(): Promise<void> {
const variant = variants[name];
const providerDisplay = variant.type === 'composite' ? 'composite' : variant.provider;
const portStr = variant.port ? String(variant.port) : '-';
return [name, providerDisplay, portStr, variant.settings || '-'];
return [name, providerDisplay, variant.target || 'claude', portStr, variant.settings || '-'];
});
console.log(
table(rows, { head: ['Variant', 'Provider', 'Port', 'Settings'], colWidths: [15, 12, 8, 30] })
table(rows, {
head: ['Variant', 'Provider', 'Target', 'Port', 'Settings'],
colWidths: [15, 12, 10, 8, 24],
})
);
console.log('');
console.log(dim(`Total: ${variantNames.length} custom variant(s)`));
+1
View File
@@ -81,6 +81,7 @@ export async function showHelp(): Promise<void> {
'Options:',
[
['--backend <type>', 'Use specific backend: original | plus (default: from config)'],
['--target <cli>', 'Default target for created/edited variants: claude | droid'],
['--verbose, -v', 'Show detailed quota fetch diagnostics'],
],
],
+181 -15
View File
@@ -12,6 +12,7 @@ import { triggerOAuth } from '../../cliproxy/auth/oauth-handler';
import { CLIProxyProfileName, CLIPROXY_PROFILES } from '../../auth/profile-detector';
import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../../cliproxy/model-catalog';
import { CLIProxyProvider, CLIProxyBackend } from '../../cliproxy/types';
import type { TargetType } from '../../targets/target-adapter';
import { isUnifiedMode } from '../../config/unified-config-loader';
import { initUI, header, color, ok, fail, warn, info, infoBox, dim } from '../../utils/ui';
import { InteractivePrompt } from '../../utils/prompt';
@@ -32,28 +33,66 @@ interface CliproxyProfileArgs {
provider?: CLIProxyProfileName;
model?: string;
account?: string;
target?: TargetType;
force?: boolean;
yes?: boolean;
composite?: boolean;
errors: string[];
}
function parseProfileArgs(args: string[]): CliproxyProfileArgs {
const result: CliproxyProfileArgs = {};
function parseTargetValue(rawValue: string): TargetType | null {
const normalized = rawValue.trim().toLowerCase();
if (normalized === 'claude' || normalized === 'droid') {
return normalized;
}
return null;
}
export function parseProfileArgs(args: string[]): CliproxyProfileArgs {
const result: CliproxyProfileArgs = { errors: [] };
let parseOptions = true;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--provider' && args[i + 1]) {
if (parseOptions && arg === '--') {
parseOptions = false;
continue;
}
if (parseOptions && arg === '--provider' && args[i + 1]) {
result.provider = args[++i] as CLIProxyProfileName;
} else if (arg === '--model' && args[i + 1]) {
} else if (parseOptions && arg === '--model' && args[i + 1]) {
result.model = args[++i];
} else if (arg === '--account' && args[i + 1]) {
} else if (parseOptions && arg === '--account' && args[i + 1]) {
result.account = args[++i];
} else if (arg === '--force') {
} else if (parseOptions && arg === '--target') {
const rawValue = args[i + 1];
if (!rawValue || rawValue.startsWith('-')) {
result.errors.push('Missing value for --target');
} else {
i += 1;
const parsedTarget = parseTargetValue(rawValue);
if (!parsedTarget) {
result.errors.push(`Invalid --target value "${rawValue}". Use: claude or droid`);
} else {
result.target = parsedTarget;
}
}
} else if (parseOptions && arg.startsWith('--target=')) {
const rawValue = arg.slice('--target='.length);
const parsedTarget = parseTargetValue(rawValue);
if (!parsedTarget) {
result.errors.push(`Invalid --target value "${rawValue}". Use: claude or droid`);
} else {
result.target = parsedTarget;
}
} else if (parseOptions && arg === '--force') {
result.force = true;
} else if (arg === '--yes' || arg === '-y') {
} else if (parseOptions && (arg === '--yes' || arg === '-y')) {
result.yes = true;
} else if (arg === '--composite') {
} else if (parseOptions && arg === '--composite') {
result.composite = true;
} else if (!arg.startsWith('-') && !result.name) {
} else if ((!parseOptions || !arg.startsWith('-')) && !result.name) {
result.name = arg;
}
}
@@ -145,6 +184,11 @@ export async function handleCreate(
): Promise<void> {
await initUI();
const parsedArgs = parseProfileArgs(args);
if (parsedArgs.errors.length > 0) {
parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exitCode = 1;
return;
}
console.log(header(`Create ${getBackendLabel(backend)} Variant`));
console.log('');
@@ -168,6 +212,17 @@ export async function handleCreate(
process.exit(1);
}
let resolvedTarget: TargetType = parsedArgs.target || 'claude';
if (!parsedArgs.target && !parsedArgs.yes) {
const useDroidByDefault = await InteractivePrompt.confirm(
'Set default target to Factory Droid for this variant?',
{ default: false }
);
if (useDroidByDefault) {
resolvedTarget = 'droid';
}
}
// Composite mode: select provider+model per tier
if (parsedArgs.composite) {
console.log(info('Composite variant — select provider and model for each tier'));
@@ -203,6 +258,7 @@ export async function handleCreate(
const result = createCompositeVariant({
name,
defaultTier,
target: resolvedTarget,
tiers: { opus, sonnet, haiku },
});
@@ -217,7 +273,8 @@ export async function handleCreate(
? `Opus: ${tiers.opus.provider} / ${tiers.opus.model}\n` +
`Sonnet: ${tiers.sonnet.provider} / ${tiers.sonnet.model}\n` +
`Haiku: ${tiers.haiku.provider} / ${tiers.haiku.model}\n` +
`Default: ${defaultTier}`
`Default: ${defaultTier}\n` +
`Target: ${resolvedTarget}`
: '';
const portInfo = result.variant?.port ? `\nPort: ${result.variant.port}` : '';
console.log(
@@ -228,7 +285,24 @@ export async function handleCreate(
);
console.log('');
console.log(header('Usage'));
console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`);
if (resolvedTarget === 'droid') {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}`
);
console.log(
` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}`
);
console.log(
` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}`
);
} else {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}`
);
console.log(
` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}`
);
}
console.log('');
return;
}
@@ -350,7 +424,7 @@ export async function handleCreate(
// Create variant
console.log('');
console.log(info(`Creating ${getBackendLabel(backend)} variant...`));
const result = createVariant(name, provider, model, account);
const result = createVariant(name, provider, model, account, resolvedTarget);
if (!result.success) {
console.log(fail(`Failed to create variant: ${result.error}`));
@@ -367,13 +441,30 @@ export async function handleCreate(
const portInfo = result.variant?.port ? `Port: ${result.variant.port}\n` : '';
console.log(
infoBox(
`Variant: ${name}\nProvider: ${provider}\nModel: ${model}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
`Variant: ${name}\nProvider: ${provider}\nModel: ${model}\nTarget: ${resolvedTarget}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
configType
)
);
console.log('');
console.log(header('Usage'));
console.log(` ${color(`ccs ${name} "your prompt"`, 'command')}`);
if (resolvedTarget === 'droid') {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}`
);
console.log(
` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}`
);
console.log(
` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}`
);
} else {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}`
);
console.log(
` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}`
);
}
console.log('');
console.log(dim('To change model later:'));
console.log(` ${color(`ccs ${name} --config`, 'command')}`);
@@ -383,6 +474,11 @@ export async function handleCreate(
export async function handleRemove(args: string[]): Promise<void> {
await initUI();
const parsedArgs = parseProfileArgs(args);
if (parsedArgs.errors.length > 0) {
parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exitCode = 1;
return;
}
const variants = listVariants();
const variantNames = Object.keys(variants);
@@ -435,6 +531,7 @@ export async function handleRemove(args: string[]): Promise<void> {
if (variant.port) {
console.log(` Port: ${variant.port}`);
}
console.log(` Target: ${variant.target || 'claude'}`);
console.log(` Settings: ${variant.settings || '-'}`);
console.log('');
@@ -461,6 +558,11 @@ export async function handleEdit(
): Promise<void> {
await initUI();
const parsedArgs = parseProfileArgs(args);
if (parsedArgs.errors.length > 0) {
parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage)));
process.exitCode = 1;
return;
}
const variants = listVariants();
const variantNames = Object.keys(variants);
@@ -501,12 +603,14 @@ export async function handleEdit(
// If not composite, use existing updateVariant() flow (interactive prompts)
if (variant.type !== 'composite') {
const currentTarget: TargetType = variant.target || 'claude';
console.log(header(`Edit Variant: ${name}`));
console.log('');
console.log(`Current provider: ${variant.provider}`);
if (variant.model) {
console.log(`Current model: ${variant.model}`);
}
console.log(`Current target: ${currentTarget}`);
console.log('');
const changeProvider = await InteractivePrompt.confirm('Change provider?', { default: false });
@@ -552,6 +656,22 @@ export async function handleEdit(
}
}
let newTarget: TargetType | undefined = parsedArgs.target;
if (!parsedArgs.target) {
const changeTarget = await InteractivePrompt.confirm('Change default target?', {
default: false,
});
if (changeTarget) {
const targetOptions = [
{ id: 'claude', label: 'Claude Code' },
{ id: 'droid', label: 'Factory Droid' },
];
newTarget = (await InteractivePrompt.selectFromList('Select target:', targetOptions, {
defaultIndex: currentTarget === 'droid' ? 1 : 0,
})) as TargetType;
}
}
console.log('');
console.log(info(`Updating ${getBackendLabel(backend)} variant...`));
// Use existing updateVariant from variant-service for single-provider variants
@@ -559,6 +679,7 @@ export async function handleEdit(
const result = updateVariant(name, {
provider: newProvider,
model: changeModel ? newModel : undefined,
target: newTarget,
});
if (!result.success) {
@@ -566,13 +687,35 @@ export async function handleEdit(
process.exit(1);
}
const resolvedTarget = result.variant?.target || currentTarget;
console.log('');
console.log(ok(`Variant updated: ${name}`));
console.log('');
console.log(header('Usage'));
if (resolvedTarget === 'droid') {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses droid by default')}`
);
console.log(
` ${color(`ccsd ${name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}`
);
console.log(
` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}`
);
} else {
console.log(
` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}`
);
console.log(
` ${color(`ccs ${name} --target droid "your prompt"`, 'command')} ${dim('# run on droid for this call')}`
);
}
console.log('');
return;
}
// Composite variant edit flow
const compositeCurrentTarget: TargetType = variant.target || 'claude';
console.log(header(`Edit Composite Variant: ${name}`));
console.log('');
if (!variant.tiers) {
@@ -585,6 +728,7 @@ export async function handleEdit(
console.log(` Sonnet: ${variant.tiers.sonnet.provider} / ${variant.tiers.sonnet.model}`);
console.log(` Haiku: ${variant.tiers.haiku.provider} / ${variant.tiers.haiku.model}`);
console.log(` Default: ${variant.default_tier}`);
console.log(` Target: ${compositeCurrentTarget}`);
console.log('');
const verbose = args.includes('--verbose');
@@ -634,11 +778,32 @@ export async function handleEdit(
)) as 'opus' | 'sonnet' | 'haiku';
}
let newCompositeTarget: TargetType | undefined = parsedArgs.target;
if (!parsedArgs.target) {
const changeTarget = await InteractivePrompt.confirm('Change default target?', {
default: false,
});
if (changeTarget) {
const targetOptions = [
{ id: 'claude', label: 'Claude Code' },
{ id: 'droid', label: 'Factory Droid' },
];
newCompositeTarget = (await InteractivePrompt.selectFromList(
'Select target:',
targetOptions,
{
defaultIndex: compositeCurrentTarget === 'droid' ? 1 : 0,
}
)) as TargetType;
}
}
console.log('');
console.log(info(`Updating composite ${getBackendLabel(backend)} variant...`));
const result = updateCompositeVariant(name, {
tiers: updatedTiers,
defaultTier: changeDefault ? newDefaultTier : undefined,
target: newCompositeTarget,
});
if (!result.success) {
@@ -653,7 +818,8 @@ export async function handleEdit(
`Opus: ${finalVariant.tiers.opus.provider} / ${finalVariant.tiers.opus.model}\n` +
`Sonnet: ${finalVariant.tiers.sonnet.provider} / ${finalVariant.tiers.sonnet.model}\n` +
`Haiku: ${finalVariant.tiers.haiku.provider} / ${finalVariant.tiers.haiku.model}\n` +
`Default: ${finalVariant.default_tier}`;
`Default: ${finalVariant.default_tier}\n` +
`Target: ${finalVariant.target || compositeCurrentTarget}`;
const portInfo = finalVariant.port ? `\nPort: ${finalVariant.port}` : '';
console.log(
infoBox(
+34 -2
View File
@@ -150,10 +150,15 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['Run multiple Claude accounts concurrently'],
[
['ccs auth --help', 'Show account management commands'],
['ccs auth create <name>', 'Create account profile (supports context sharing flags)'],
[
'ccs auth create <name>',
'Create account profile (supports shared groups + --deeper-continuity)',
],
['ccs config', 'Dashboard: Accounts table can edit context mode/group/continuity depth'],
['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'],
]
);
@@ -167,6 +172,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
'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',
],
@@ -188,6 +194,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs <provider> --accounts', 'List all accounts'],
['ccs <provider> --use <name>', '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)',
@@ -326,6 +336,21 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
printSubSection('Multi-Target', [
['ccs glm --target droid', 'Run GLM profile on Droid CLI'],
['ccsd glm', 'Same as above (alias)'],
['ccsd codex', 'Run built-in CLIProxy Codex profile on Droid'],
['ccsd agy', 'Run built-in CLIProxy Antigravity profile on Droid'],
[
'ccsd codex exec --skip-permissions-unsafe "fix failing tests"',
'Pass through Droid exec mode',
],
['ccsd codex -m custom:gpt-5.3-codex "fix failing tests"', 'Auto-routes short exec flags'],
[
'ccsd 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)'],
]);
@@ -347,7 +372,9 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['', ''], // Spacer
['ccs cliproxy pause <p> <a>', 'Pause account from rotation'],
['ccs cliproxy resume <p> <a>', 'Resume paused account'],
['ccs cliproxy status [provider]', 'Show quota/tier/pause status'],
['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'],
]);
// CLI Proxy configuration flags (new)
@@ -375,6 +402,11 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['--effort <level>', 'Codex alias for reasoning effort (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).'],
+21
View File
@@ -21,6 +21,7 @@ import type { ProfileConfig, AccountConfig, CLIProxyVariantConfig } from './unif
import { createEmptyUnifiedConfig } from './unified-config-types';
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
import { saveUnifiedConfig, hasUnifiedConfig, loadUnifiedConfig } from './unified-config-loader';
import { isValidContextGroupName, normalizeContextGroupName } from '../auth/account-context';
import { infoBox, warn } from '../utils/ui';
const BACKUP_DIR_PREFIX = 'backup-v1-';
@@ -148,9 +149,29 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
if (oldProfiles?.profiles) {
for (const [name, meta] of Object.entries(oldProfiles.profiles)) {
const metadata = meta as Record<string, unknown>;
const rawContextMode = metadata.context_mode;
const rawContextGroup = metadata.context_group;
const rawContinuityMode = metadata.continuity_mode;
const contextMode = rawContextMode === 'shared' ? 'shared' : 'isolated';
const continuityMode =
contextMode === 'shared' && rawContinuityMode === 'deeper' ? 'deeper' : 'standard';
let contextGroup: string | undefined;
if (typeof rawContextGroup === 'string' && rawContextGroup.trim().length > 0) {
const normalizedGroup = normalizeContextGroupName(rawContextGroup);
if (isValidContextGroupName(normalizedGroup)) {
contextGroup = normalizedGroup;
} else {
warnings.push(
`Skipped invalid context group for account "${name}": "${rawContextGroup}" (fallback to default shared group)`
);
}
}
const account: AccountConfig = {
created: (metadata.created as string) || new Date().toISOString(),
last_used: (metadata.last_used as string) || null,
context_mode: contextMode,
context_group: contextMode === 'shared' ? contextGroup : undefined,
continuity_mode: contextMode === 'shared' ? continuityMode : undefined,
};
unifiedConfig.accounts[name] = account;
}
+137 -58
View File
@@ -18,10 +18,12 @@ import {
DEFAULT_CURSOR_CONFIG,
DEFAULT_GLOBAL_ENV,
DEFAULT_CLIPROXY_SERVER_CONFIG,
DEFAULT_CLIPROXY_SAFETY_CONFIG,
DEFAULT_QUOTA_MANAGEMENT_CONFIG,
DEFAULT_THINKING_CONFIG,
DEFAULT_DASHBOARD_AUTH_CONFIG,
DEFAULT_IMAGE_ANALYSIS_CONFIG,
CLIProxySafetyConfig,
GlobalEnvConfig,
ThinkingConfig,
DashboardAuthConfig,
@@ -241,6 +243,7 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
accounts: partial.accounts ?? defaults.accounts,
profiles: partial.profiles ?? defaults.profiles,
cliproxy: {
...partial.cliproxy,
oauth_accounts: partial.cliproxy?.oauth_accounts ?? defaults.cliproxy.oauth_accounts,
providers: defaults.cliproxy.providers, // Always use defaults for providers
variants: partial.cliproxy?.variants ?? defaults.cliproxy.variants,
@@ -249,8 +252,17 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
request_log:
partial.cliproxy?.logging?.request_log ?? defaults.cliproxy.logging?.request_log ?? false,
},
safety: {
antigravity_ack_bypass:
partial.cliproxy?.safety?.antigravity_ack_bypass ??
DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass,
},
// Kiro browser behavior setting (optional)
kiro_no_incognito: partial.cliproxy?.kiro_no_incognito,
// Auth config - preserve user values, no defaults (uses constants as fallback)
auth: partial.cliproxy?.auth,
// Background token refresh config (optional)
token_refresh: partial.cliproxy?.token_refresh,
// Backend selection - validate and preserve user choice (original vs plus)
backend:
partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus'
@@ -707,14 +719,24 @@ function generateYamlWithComments(config: UnifiedConfig): string {
}
/**
* Save unified config to YAML file.
* Uses atomic write (temp file + rename) to prevent corruption.
* Uses lockfile to prevent concurrent writes.
* Sync sleep helper for lock retry loops.
* Uses Atomics.wait when available to avoid CPU-intensive busy-wait.
*/
export function saveUnifiedConfig(config: UnifiedConfig): void {
const yamlPath = getConfigYamlPath();
const dir = path.dirname(yamlPath);
function sleepSync(ms: number): void {
try {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
} catch {
const end = Date.now() + ms;
while (Date.now() < end) {
/* busy-wait */
}
}
}
/**
* Execute a callback while holding the config lock.
*/
function withConfigWriteLock<T>(callback: () => T): T {
// Acquire lock (retry for up to 1 second)
const maxRetries = 10;
const retryDelayMs = 100;
@@ -724,18 +746,7 @@ export function saveUnifiedConfig(config: UnifiedConfig): void {
lockAcquired = true;
break;
}
// Synchronous sleep without CPU-intensive busy-wait
// Uses Atomics.wait which properly sleeps the thread
// Note: saveUnifiedConfig is sync API with 19+ callers, converting to async not feasible
try {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelayMs);
} catch {
// Fallback for environments without SharedArrayBuffer/Atomics support
const end = Date.now() + retryDelayMs;
while (Date.now() < end) {
/* busy-wait */
}
}
sleepSync(retryDelayMs);
}
if (!lockAcquired) {
@@ -743,57 +754,112 @@ export function saveUnifiedConfig(config: UnifiedConfig): void {
}
try {
// Ensure directory exists
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
// Ensure version is set
config.version = UNIFIED_CONFIG_VERSION;
// Generate YAML with section comments
const yamlContent = generateYamlWithComments(config);
const content = generateYamlHeader() + yamlContent;
// Atomic write: write to temp file, then rename
const tempPath = `${yamlPath}.tmp.${process.pid}`;
try {
fs.writeFileSync(tempPath, content, { mode: 0o600 });
fs.renameSync(tempPath, yamlPath);
} catch (error) {
// Clean up temp file on error
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
// Classify filesystem errors
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOSPC') {
throw new Error('Disk full - cannot save config. Free up space and try again.');
} else if (err.code === 'EROFS' || err.code === 'EACCES') {
throw new Error(`Cannot write config - check file permissions: ${err.message}`);
}
throw error;
}
return callback();
} finally {
// Always release lock
releaseLock();
}
}
/**
* Load unified config directly from disk while lock is already held.
* Falls back to empty config when file doesn't exist.
*/
function loadUnifiedConfigWithLockHeld(): UnifiedConfig {
const yamlPath = getConfigYamlPath();
if (!fs.existsSync(yamlPath)) {
return createEmptyUnifiedConfig();
}
const content = fs.readFileSync(yamlPath, 'utf8');
const parsed = yaml.load(content);
if (!isUnifiedConfig(parsed)) {
throw new Error(`Invalid config format in ${yamlPath}`);
}
const merged = mergeWithDefaults(parsed);
validateCompositeVariants(merged);
return merged;
}
/**
* Write unified config to disk while lock is already held.
*/
function writeUnifiedConfigWithLockHeld(config: UnifiedConfig): void {
const yamlPath = getConfigYamlPath();
const dir = path.dirname(yamlPath);
// Ensure directory exists
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
// Ensure version is set
config.version = UNIFIED_CONFIG_VERSION;
// Generate YAML with section comments
const yamlContent = generateYamlWithComments(config);
const content = generateYamlHeader() + yamlContent;
// Atomic write: write to temp file, then rename
const tempPath = `${yamlPath}.tmp.${process.pid}`;
try {
fs.writeFileSync(tempPath, content, { mode: 0o600 });
fs.renameSync(tempPath, yamlPath);
} catch (error) {
// Clean up temp file on error
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
// Classify filesystem errors
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOSPC') {
throw new Error('Disk full - cannot save config. Free up space and try again.');
} else if (err.code === 'EROFS' || err.code === 'EACCES') {
throw new Error(`Cannot write config - check file permissions: ${err.message}`);
}
throw error;
}
}
/**
* Save unified config to YAML file.
* Uses atomic write (temp file + rename) to prevent corruption.
* Uses lockfile to prevent concurrent writes.
*/
export function saveUnifiedConfig(config: UnifiedConfig): void {
withConfigWriteLock(() => {
writeUnifiedConfigWithLockHeld(config);
});
}
/**
* Atomically mutate unified config with lock held across read-modify-write.
* Prevents stale writes from overwriting concurrent updates.
*/
export function mutateUnifiedConfig(mutator: (config: UnifiedConfig) => void): UnifiedConfig {
return withConfigWriteLock(() => {
const current = loadUnifiedConfigWithLockHeld();
mutator(current);
writeUnifiedConfigWithLockHeld(current);
return current;
});
}
/**
* Update unified config with partial data.
* Loads existing config, merges changes, and saves.
*/
export function updateUnifiedConfig(updates: Partial<UnifiedConfig>): UnifiedConfig {
const config = loadOrCreateUnifiedConfig();
const updated = { ...config, ...updates };
saveUnifiedConfig(updated);
return updated;
return mutateUnifiedConfig((config) => {
Object.assign(config, updates);
});
}
/**
@@ -892,6 +958,19 @@ export function getGlobalEnvConfig(): GlobalEnvConfig {
};
}
/**
* Get cliproxy safety configuration.
* Returns defaults if not configured.
*/
export function getCliproxySafetyConfig(): CLIProxySafetyConfig {
const config = loadOrCreateUnifiedConfig();
return {
antigravity_ack_bypass:
config.cliproxy?.safety?.antigravity_ack_bypass ??
DEFAULT_CLIPROXY_SAFETY_CONFIG.antigravity_ack_bypass,
};
}
/**
* Get thinking configuration.
* Returns defaults if not configured.
+21
View File
@@ -44,6 +44,8 @@ export interface AccountConfig {
context_mode?: 'isolated' | 'shared';
/** Context-sharing group when context_mode='shared' */
context_group?: string;
/** Shared continuity depth when context_mode='shared' */
continuity_mode?: 'standard' | 'deeper';
}
/**
@@ -160,6 +162,22 @@ export interface CLIProxyLoggingConfig {
request_log?: boolean;
}
/**
* CLIProxy safety configuration.
* Controls high-risk flow safeguards for supported providers.
*/
export interface CLIProxySafetyConfig {
/** Allow skipping AGY responsibility acknowledgement flow (default: false) */
antigravity_ack_bypass?: boolean;
}
/**
* Default CLIProxy safety configuration.
*/
export const DEFAULT_CLIPROXY_SAFETY_CONFIG: CLIProxySafetyConfig = {
antigravity_ack_bypass: false,
};
/**
* Token refresh configuration.
* Manages background token refresh worker settings.
@@ -191,6 +209,8 @@ export interface CLIProxyConfig {
variants: Record<string, CLIProxyVariantConfig | CompositeVariantConfig>;
/** Logging configuration (disabled by default) */
logging?: CLIProxyLoggingConfig;
/** Safety controls for high-risk provider flows */
safety?: CLIProxySafetyConfig;
/** Kiro: disable incognito browser mode (use normal browser to save credentials) */
kiro_no_incognito?: boolean;
/** Global auth configuration for CLIProxyAPI */
@@ -781,6 +801,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
enabled: false,
request_log: false,
},
safety: { ...DEFAULT_CLIPROXY_SAFETY_CONFIG },
auto_sync: true,
},
preferences: {
+15 -8
View File
@@ -9,6 +9,7 @@
import * as fs from 'fs';
import * as path from 'path';
import SharedManager from './shared-manager';
import ProfileContextSyncLock from './profile-context-sync-lock';
import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context';
import { getCcsDir } from '../utils/config-manager';
@@ -18,10 +19,12 @@ import { getCcsDir } from '../utils/config-manager';
class InstanceManager {
private readonly instancesDir: string;
private readonly sharedManager: SharedManager;
private readonly contextSyncLock: ProfileContextSyncLock;
constructor() {
this.instancesDir = path.join(getCcsDir(), 'instances');
this.sharedManager = new SharedManager();
this.contextSyncLock = new ProfileContextSyncLock(this.instancesDir);
}
/**
@@ -33,16 +36,20 @@ class InstanceManager {
): Promise<string> {
const instancePath = this.getInstancePath(profileName);
// Lazy initialization
if (!fs.existsSync(instancePath)) {
this.initializeInstance(profileName, instancePath);
}
// Serialize context sync operations per profile across processes.
await this.contextSyncLock.withLock(profileName, async () => {
// Lazy initialization
if (!fs.existsSync(instancePath)) {
this.initializeInstance(profileName, instancePath);
}
// Validate structure (auto-fix missing dirs)
this.validateInstance(instancePath);
// Validate structure (auto-fix missing dirs)
this.validateInstance(instancePath);
// Apply context policy (isolated by default, optional shared group).
await this.sharedManager.syncProjectContext(instancePath, contextPolicy);
// Apply context policy (isolated by default, optional shared group).
await this.sharedManager.syncProjectContext(instancePath, contextPolicy);
await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy);
});
return instancePath;
}
+177
View File
@@ -0,0 +1,177 @@
import * as fs from 'fs';
import * as path from 'path';
import { createHash } from 'crypto';
interface ContextSyncLockPayload {
version: 1;
pid: number;
nonce: string;
acquiredAtMs: number;
}
interface ContextSyncLockSnapshot {
raw: string;
owner: { pid: number; nonce?: string } | null;
}
class ProfileContextSyncLock {
private readonly locksDir: string;
constructor(instancesDir: string) {
this.locksDir = path.join(instancesDir, '.locks');
}
private sanitizeName(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
}
private getLockPath(profileName: string): string {
const safeName = this.sanitizeName(profileName);
const profileHash = createHash('sha1').update(profileName).digest('hex').slice(0, 8);
return path.join(this.locksDir, `${safeName}-${profileHash}.lock`);
}
private isProcessAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EPERM') {
return true;
}
return false;
}
}
private parseContextSyncLock(raw: string): { pid: number; nonce?: string } | null {
const trimmed = raw.trim();
if (trimmed.length === 0) {
return null;
}
try {
const parsed = JSON.parse(trimmed) as Partial<ContextSyncLockPayload>;
if (typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0) {
const nonce =
typeof parsed.nonce === 'string' && parsed.nonce.length > 0 ? parsed.nonce : undefined;
return { pid: parsed.pid, nonce };
}
} catch {
const legacyPid = Number.parseInt(trimmed, 10);
if (Number.isInteger(legacyPid) && legacyPid > 0) {
return { pid: legacyPid };
}
}
return null;
}
private readContextSyncLockSnapshot(lockPath: string): ContextSyncLockSnapshot | null {
try {
const raw = fs.readFileSync(lockPath, 'utf8');
return {
raw,
owner: this.parseContextSyncLock(raw),
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
return null;
}
}
private tryRemoveLockIfUnchanged(lockPath: string, expectedRaw: string): boolean {
try {
const currentRaw = fs.readFileSync(lockPath, 'utf8');
if (currentRaw !== expectedRaw) {
return false;
}
fs.unlinkSync(lockPath);
return true;
} catch {
return false;
}
}
private tryRemoveDeadOwnerLock(lockPath: string, snapshot: ContextSyncLockSnapshot): boolean {
if (!snapshot.owner || this.isProcessAlive(snapshot.owner.pid)) {
return false;
}
return this.tryRemoveLockIfUnchanged(lockPath, snapshot.raw);
}
async withLock<T>(profileName: string, callback: () => Promise<T>): Promise<T> {
const lockPath = this.getLockPath(profileName);
const retryDelayMs = 50;
const staleLockMs = 30000;
const timeoutMs = staleLockMs + 5000;
const start = Date.now();
const ownerPayload: ContextSyncLockPayload = {
version: 1,
pid: process.pid,
nonce: createHash('sha1')
.update(`${process.pid}:${Date.now()}:${Math.random()}`)
.digest('hex')
.slice(0, 16),
acquiredAtMs: Date.now(),
};
const ownerPayloadRaw = JSON.stringify(ownerPayload);
fs.mkdirSync(this.locksDir, { recursive: true, mode: 0o700 });
while (true) {
try {
const fd = fs.openSync(lockPath, 'wx', 0o600);
fs.writeFileSync(fd, ownerPayloadRaw, 'utf8');
fs.closeSync(fd);
break;
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== 'EEXIST') {
throw error;
}
const lockSnapshot = this.readContextSyncLockSnapshot(lockPath);
if (lockSnapshot) {
if (this.tryRemoveDeadOwnerLock(lockPath, lockSnapshot)) {
continue;
}
// For malformed lock payloads, fall back to age-based stale cleanup.
if (!lockSnapshot.owner) {
try {
const lockStats = fs.statSync(lockPath);
if (Date.now() - lockStats.mtimeMs > staleLockMs) {
if (this.tryRemoveLockIfUnchanged(lockPath, lockSnapshot.raw)) {
continue;
}
}
} catch {
// Best-effort stale lock cleanup.
}
}
}
if (Date.now() - start > timeoutMs) {
throw new Error(`Timed out waiting for profile context lock: ${profileName}`);
}
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
}
}
try {
return await callback();
} finally {
this.tryRemoveLockIfUnchanged(lockPath, ownerPayloadRaw);
}
}
}
export default ProfileContextSyncLock;
+231 -6
View File
@@ -27,6 +27,12 @@ class SharedManager {
private readonly claudeDir: string;
private readonly instancesDir: string;
private readonly sharedItems: SharedItem[];
private readonly advancedContinuityItems: readonly string[] = [
'session-env',
'file-history',
'shell-snapshots',
'todos',
];
constructor() {
this.homeDir = os.homedir();
@@ -63,8 +69,14 @@ class SharedManager {
const resolvedTarget = path.resolve(path.dirname(target), targetLink);
// Check if target points back to our shared dir or link path
const sharedDir = path.join(getCcsDir(), 'shared');
if (resolvedTarget.startsWith(sharedDir) || resolvedTarget === linkPath) {
const sharedDir = this.resolveCanonicalPath(path.join(getCcsDir(), 'shared'));
const canonicalResolvedTarget = this.resolveCanonicalPath(resolvedTarget);
const canonicalLinkPath = this.resolveCanonicalPath(linkPath);
if (
this.isPathWithinDirectory(canonicalResolvedTarget, sharedDir) ||
canonicalResolvedTarget === canonicalLinkPath
) {
console.log(warn(`Circular symlink detected: ${target}${resolvedTarget}`));
return true;
}
@@ -240,6 +252,7 @@ class SharedManager {
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(sharedProjectsPath) &&
this.isSafeProjectsMergeSource(currentTarget, instanceName) &&
(await this.pathExists(currentTarget))
) {
await this.mergeDirectoryWithConflictCopies(
@@ -247,6 +260,10 @@ class SharedManager {
sharedProjectsPath,
instanceName
);
} else if (currentTarget && !this.isSafeProjectsMergeSource(currentTarget, instanceName)) {
console.log(
warn(`Skipping unsafe project merge source outside CCS roots: ${currentTarget}`)
);
}
await fs.promises.unlink(projectsPath);
@@ -286,9 +303,14 @@ class SharedManager {
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(projectsPath) &&
this.isSafeProjectsMergeSource(currentTarget, instanceName) &&
(await this.pathExists(currentTarget))
) {
await this.mergeDirectoryWithConflictCopies(currentTarget, projectsPath, instanceName);
} else if (currentTarget && !this.isSafeProjectsMergeSource(currentTarget, instanceName)) {
console.log(
warn(`Skipping unsafe project merge source outside CCS roots: ${currentTarget}`)
);
}
return;
@@ -298,6 +320,133 @@ class SharedManager {
await this.ensureDirectory(projectsPath);
}
/**
* Sync advanced continuity artifacts for shared deeper mode.
*
* - shared + deeper: artifacts are linked per context group.
* - shared + standard / isolated: artifacts stay local to instance.
*/
async syncAdvancedContinuityArtifacts(
instancePath: string,
policy: AccountContextPolicy
): Promise<void> {
const instanceName = path.basename(instancePath);
const useSharedContinuity = policy.mode === 'shared' && policy.continuityMode === 'deeper';
const contextGroup = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP;
for (const artifactName of this.advancedContinuityItems) {
const instanceArtifactPath = path.join(instancePath, artifactName);
if (useSharedContinuity) {
const sharedArtifactPath = path.join(
this.sharedDir,
'context-groups',
contextGroup,
'continuity',
artifactName
);
await this.ensureDirectory(sharedArtifactPath);
await this.ensureDirectory(path.dirname(instanceArtifactPath));
const currentStats = await this.getLstat(instanceArtifactPath);
if (!currentStats) {
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
continue;
}
if (currentStats.isSymbolicLink()) {
if (await this.isSymlinkTarget(instanceArtifactPath, sharedArtifactPath)) {
continue;
}
const currentTarget = await this.resolveSymlinkTargetPath(instanceArtifactPath);
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(sharedArtifactPath) &&
this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) &&
(await this.pathExists(currentTarget))
) {
await this.mergeDirectoryWithConflictCopies(
currentTarget,
sharedArtifactPath,
instanceName
);
} else if (
currentTarget &&
!this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName)
) {
console.log(
warn(
`Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}`
)
);
}
await fs.promises.unlink(instanceArtifactPath);
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
continue;
}
if (currentStats.isDirectory()) {
await this.mergeDirectoryWithConflictCopies(
instanceArtifactPath,
sharedArtifactPath,
instanceName
);
await fs.promises.rm(instanceArtifactPath, { recursive: true, force: true });
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
continue;
}
await fs.promises.rm(instanceArtifactPath, { force: true });
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
continue;
}
const currentStats = await this.getLstat(instanceArtifactPath);
if (!currentStats) {
await this.ensureDirectory(instanceArtifactPath);
continue;
}
if (currentStats.isDirectory()) {
continue;
}
if (currentStats.isSymbolicLink()) {
const currentTarget = await this.resolveSymlinkTargetPath(instanceArtifactPath);
await fs.promises.unlink(instanceArtifactPath);
await this.ensureDirectory(instanceArtifactPath);
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(instanceArtifactPath) &&
this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) &&
(await this.pathExists(currentTarget))
) {
await this.mergeDirectoryWithConflictCopies(
currentTarget,
instanceArtifactPath,
instanceName
);
} else if (
currentTarget &&
!this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName)
) {
console.log(
warn(`Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}`)
);
}
continue;
}
await fs.promises.rm(instanceArtifactPath, { force: true });
await this.ensureDirectory(instanceArtifactPath);
}
}
/**
* Ensure all project memory directories for an instance are shared.
*
@@ -678,6 +827,56 @@ class SharedManager {
}
}
/**
* Guard project merge operations to known CCS-managed roots only.
*/
private isSafeProjectsMergeSource(sourcePath: string, instanceName: string): boolean {
const resolvedSource = this.resolveCanonicalPath(sourcePath);
const sharedContextRoot = this.resolveCanonicalPath(
path.join(this.sharedDir, 'context-groups')
);
const instanceProjectsRoot = this.resolveCanonicalPath(
path.join(this.instancesDir, instanceName, 'projects')
);
return (
this.isPathWithinDirectory(resolvedSource, sharedContextRoot) ||
this.isPathWithinDirectory(resolvedSource, instanceProjectsRoot)
);
}
/**
* Guard advanced continuity merge operations to known CCS-managed roots only.
*/
private isSafeContinuityMergeSource(
sourcePath: string,
instanceName: string,
artifactName: string
): boolean {
const resolvedSource = this.resolveCanonicalPath(sourcePath);
const sharedContextRoot = this.resolveCanonicalPath(
path.join(this.sharedDir, 'context-groups')
);
const instanceArtifactRoot = this.resolveCanonicalPath(
path.join(this.instancesDir, instanceName, artifactName)
);
const normalizedSource =
process.platform === 'win32' ? resolvedSource.toLowerCase() : resolvedSource;
const continuitySegment =
process.platform === 'win32'
? `${path.sep}continuity${path.sep}`.toLowerCase()
: `${path.sep}continuity${path.sep}`;
const withinSharedContinuity =
this.isPathWithinDirectory(resolvedSource, sharedContextRoot) &&
normalizedSource.includes(continuitySegment);
return (
withinSharedContinuity || this.isPathWithinDirectory(resolvedSource, instanceArtifactRoot)
);
}
/**
* Link directory with Windows fallback to recursive copy.
*/
@@ -708,7 +907,7 @@ class SharedManager {
projectsPath: string,
instanceName: string
): Promise<void> {
const sharedMemoryRoot = path.resolve(path.join(this.sharedDir, 'memory'));
const sharedMemoryRoot = this.resolveCanonicalPath(path.join(this.sharedDir, 'memory'));
let projectEntries: fs.Dirent[] = [];
try {
@@ -735,15 +934,20 @@ class SharedManager {
continue;
}
if (!path.resolve(memoryTarget).startsWith(sharedMemoryRoot)) {
const canonicalMemoryTarget = this.resolveCanonicalPath(memoryTarget);
if (!this.isPathWithinDirectory(canonicalMemoryTarget, sharedMemoryRoot)) {
continue;
}
await fs.promises.unlink(memoryPath);
await this.ensureDirectory(memoryPath);
if (await this.pathExists(memoryTarget)) {
await this.mergeDirectoryWithConflictCopies(memoryTarget, memoryPath, instanceName);
if (await this.pathExists(canonicalMemoryTarget)) {
await this.mergeDirectoryWithConflictCopies(
canonicalMemoryTarget,
memoryPath,
instanceName
);
}
}
}
@@ -851,6 +1055,27 @@ class SharedManager {
return candidate;
}
private resolveCanonicalPath(targetPath: string): string {
try {
return fs.realpathSync.native(targetPath);
} catch {
return path.resolve(targetPath);
}
}
private isPathWithinDirectory(candidatePath: string, rootPath: string): boolean {
const normalizeForCompare = (inputPath: string): string => {
const resolved = path.resolve(inputPath);
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
};
const normalizedCandidate = normalizeForCompare(candidatePath);
const normalizedRoot = normalizeForCompare(rootPath);
const relative = path.relative(normalizedRoot, normalizedCandidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
private async pathExists(targetPath: string): Promise<boolean> {
try {
await fs.promises.access(targetPath);
+20 -10
View File
@@ -2,7 +2,7 @@
* Droid Adapter
*
* TargetAdapter implementation for Factory Droid CLI.
* Writes credentials to ~/.factory/settings.json and spawns `droid -m custom:ccs-<profile>`.
* Writes credentials + active model to ~/.factory/settings.json and spawns `droid`.
*/
import { spawn, ChildProcess } from 'child_process';
@@ -11,6 +11,7 @@ import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from '
import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector';
import type { ProfileType } from '../types/profile';
import { upsertCcsModel } from './droid-config-manager';
import { resolveDroidProvider } from './droid-provider';
import { escapeShellArg } from '../utils/shell-executor';
import { wireChildProcessSignals } from '../utils/signal-forwarder';
import { runCleanup } from '../errors';
@@ -43,22 +44,33 @@ export class DroidAdapter implements TargetAdapter {
*/
async prepareCredentials(creds: TargetCredentials): Promise<void> {
this.validateCredentials(creds);
await upsertCcsModel(creds.profile, {
const provider = resolveDroidProvider({
provider: creds.provider,
baseUrl: creds.baseUrl,
model: creds.model,
});
const modelRef = await upsertCcsModel(creds.profile, {
model: creds.model || 'claude-opus-4-6',
displayName: `CCS ${creds.profile}`,
baseUrl: creds.baseUrl,
apiKey: creds.apiKey,
provider: creds.provider || 'anthropic',
provider,
reasoningOverride: creds.reasoningOverride,
});
if (!modelRef.selector) {
throw new Error(`Failed to resolve Droid model selector for profile "${creds.profile}"`);
}
}
buildArgs(profile: string, userArgs: string[]): string[] {
if (!/^[a-zA-Z0-9_-]+$/.test(profile)) {
if (!/^[a-zA-Z0-9._-]+$/.test(profile)) {
throw new Error(
`Invalid profile name "${profile}" for Droid target: only alphanumeric, underscore, hyphen allowed`
`Invalid profile name "${profile}" for Droid target: only alphanumeric, dot, underscore, hyphen allowed`
);
}
return ['-m', `custom:ccs-${profile}`, ...userArgs];
// Droid interactive mode treats unknown argv as queued prompt text.
// Model selection must be persisted in settings.json (`model`) instead of `-m`.
return [...userArgs];
}
/**
@@ -154,10 +166,8 @@ export class DroidAdapter implements TargetAdapter {
});
}
/**
* Droid currently supports direct settings-based and default flows only.
*/
/** Droid supports settings/default and CLIProxy-executed profile flows. */
supportsProfileType(profileType: ProfileType): boolean {
return profileType === 'settings' || profileType === 'default';
return profileType === 'settings' || profileType === 'default' || profileType === 'cliproxy';
}
}
+289
View File
@@ -0,0 +1,289 @@
/**
* Droid command router
*
* Determines whether profile args should launch Droid interactive mode
* (`droid [prompt...]`) or command mode (`droid <subcommand> ...`).
*
* Also normalizes CCS legacy reasoning aliases for `droid exec`:
* - --effort / --thinking -> --reasoning-effort
*/
export type DroidCommandMode = 'interactive' | 'command';
export interface DroidCommandRoute {
mode: DroidCommandMode;
argsForDroid: string[];
command?: string;
autoPrependedExec: boolean;
reasoningSourceDisplay?: string;
duplicateReasoningDisplays: string[];
}
type DroidReasoningFlag = '--reasoning-effort' | '-r' | '--effort' | '--thinking';
export class DroidCommandRouterError extends Error {
constructor(
message: string,
public readonly flag: DroidReasoningFlag
) {
super(message);
this.name = 'DroidCommandRouterError';
}
}
const DROID_SUBCOMMANDS = new Set([
'exec',
'mcp',
'plugin',
'daemon',
'search',
'find',
'ssh',
'computer',
'update',
'help',
]);
// Exec-only long flags from Factory Droid CLI help.
const DROID_EXEC_ONLY_LONG_FLAGS = new Set([
'--output-format',
'--input-format',
'--file',
'--auto',
'--skip-permissions-unsafe',
'--session-id',
'--model',
'--reasoning-effort',
'--enabled-tools',
'--disabled-tools',
'--cwd',
'--tag',
'--log-group-id',
'--list-tools',
]);
const DROID_EXEC_ONLY_SHORT_FLAGS = new Set(['-o', '-f', '-s', '-m']);
const DROID_REASONING_EFFORT_VALUES = new Set([
'none',
'off',
'minimal',
'low',
'medium',
'high',
'max',
'xhigh',
'auto',
]);
function getLongFlagToken(arg: string): string {
const eqIndex = arg.indexOf('=');
return eqIndex >= 0 ? arg.slice(0, eqIndex) : arg;
}
function isExplicitSubcommand(arg: string | undefined): boolean {
return !!arg && DROID_SUBCOMMANDS.has(arg);
}
function isLikelyReasoningEffortValue(value: string | undefined): boolean {
if (!value || value.startsWith('-')) return false;
return DROID_REASONING_EFFORT_VALUES.has(value.toLowerCase());
}
function hasExecOnlyFlagsAtFront(args: string[]): boolean {
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--') return false;
// CCS legacy aliases may appear before exec-only flags; skip their values when present.
if (arg === '--effort' || arg === '--thinking') {
const possibleValue = args[i + 1];
if (possibleValue && !possibleValue.startsWith('-')) {
i += 1;
}
continue;
}
if (arg.startsWith('--effort=') || arg.startsWith('--thinking=')) {
continue;
}
if (!arg.startsWith('-')) return false;
if (!arg.startsWith('--')) {
// Short flags:
// - `-r` is ambiguous (root resume vs exec reasoning-effort), so only route
// when value looks like a reasoning effort level.
if (DROID_EXEC_ONLY_SHORT_FLAGS.has(arg)) {
return true;
}
if (arg === '-r') {
const value = args[i + 1];
return isLikelyReasoningEffortValue(value);
}
continue;
}
const flagToken = getLongFlagToken(arg);
if (DROID_EXEC_ONLY_LONG_FLAGS.has(flagToken)) {
return true;
}
}
return false;
}
interface ExecReasoningNormalizationResult {
args: string[];
sourceDisplay?: string;
duplicateDisplays: string[];
}
function normalizeExecReasoningFlags(args: string[]): ExecReasoningNormalizationResult {
const normalized: string[] = [];
const duplicateDisplays: string[] = [];
let sourceDisplay: string | undefined;
let hasReasoning = false;
const applyReasoning = (value: string, display: string): void => {
if (!hasReasoning) {
normalized.push('--reasoning-effort', value);
hasReasoning = true;
sourceDisplay = display;
return;
}
duplicateDisplays.push(display);
};
const handleMissingValue = (
flag: DroidReasoningFlag,
missingDisplay: string
): never | undefined => {
if (!hasReasoning) {
throw new DroidCommandRouterError(`${flag} requires a value`, flag);
}
duplicateDisplays.push(missingDisplay);
return undefined;
};
// Preserve leading command token for explicit auto-prepended command mode.
const startsWithExec = args[0] === 'exec';
let startIndex = 0;
if (startsWithExec) {
normalized.push('exec');
startIndex = 1;
}
for (let i = startIndex; i < args.length; i++) {
const arg = args[i];
if (arg === '--') {
normalized.push(...args.slice(i));
break;
}
if (
arg === '--reasoning-effort' ||
arg === '--effort' ||
arg === '--thinking' ||
arg === '-r'
) {
const value = args[i + 1];
if (!value || value.startsWith('-')) {
handleMissingValue(arg as DroidReasoningFlag, `${arg} <missing-value>`);
continue;
}
applyReasoning(value, `${arg} ${value}`);
i += 1;
continue;
}
if (arg.startsWith('--reasoning-effort=')) {
const value = arg.slice('--reasoning-effort='.length);
if (!value) {
handleMissingValue('--reasoning-effort', '--reasoning-effort=<missing-value>');
continue;
}
applyReasoning(value, `--reasoning-effort=${value}`);
continue;
}
if (arg.startsWith('--effort=')) {
const value = arg.slice('--effort='.length);
if (!value) {
handleMissingValue('--effort', '--effort=<missing-value>');
continue;
}
applyReasoning(value, `--effort=${value}`);
continue;
}
if (arg.startsWith('--thinking=')) {
const value = arg.slice('--thinking='.length);
if (!value) {
handleMissingValue('--thinking', '--thinking=<missing-value>');
continue;
}
applyReasoning(value, `--thinking=${value}`);
continue;
}
normalized.push(arg);
}
return {
args: normalized,
sourceDisplay,
duplicateDisplays,
};
}
export function routeDroidCommandArgs(args: string[]): DroidCommandRoute {
if (args.length === 0) {
return {
mode: 'interactive',
argsForDroid: [],
autoPrependedExec: false,
duplicateReasoningDisplays: [],
};
}
if (isExplicitSubcommand(args[0])) {
const command = args[0];
const normalized =
command === 'exec'
? normalizeExecReasoningFlags(args)
: {
args: [...args],
duplicateDisplays: [],
};
return {
mode: 'command',
command,
argsForDroid: normalized.args,
autoPrependedExec: false,
reasoningSourceDisplay: normalized.sourceDisplay,
duplicateReasoningDisplays: normalized.duplicateDisplays,
};
}
if (hasExecOnlyFlagsAtFront(args)) {
const argsWithExec = ['exec', ...args];
const normalized = normalizeExecReasoningFlags(argsWithExec);
return {
mode: 'command',
command: 'exec',
argsForDroid: normalized.args,
autoPrependedExec: true,
reasoningSourceDisplay: normalized.sourceDisplay,
duplicateReasoningDisplays: normalized.duplicateDisplays,
};
}
return {
mode: 'interactive',
argsForDroid: [...args],
autoPrependedExec: false,
duplicateReasoningDisplays: [],
};
}
+171 -9
View File
@@ -20,16 +20,16 @@ const LOCK_RETRY_MAX_MS = 1000;
/**
* Validate profile name to prevent filesystem/security issues.
* Only alphanumeric, underscore, hyphen allowed.
* Only alphanumeric, dot, underscore, hyphen allowed.
*/
function isValidProfileName(profile: string): boolean {
return !!profile && /^[a-zA-Z0-9_-]+$/.test(profile);
return !!profile && /^[a-zA-Z0-9._-]+$/.test(profile);
}
function validateProfileName(profile: string): void {
if (!isValidProfileName(profile)) {
throw new Error(
`Invalid profile name "${profile}": must contain only alphanumeric characters, underscores, or hyphens`
`Invalid profile name "${profile}": must contain only alphanumeric characters, dots, underscores, or hyphens`
);
}
}
@@ -41,9 +41,19 @@ export interface DroidCustomModel {
apiKey: string;
provider: 'anthropic' | 'openai' | 'generic-chat-completion-api';
maxOutputTokens?: number;
reasoningOverride?: string | number;
}
export interface DroidManagedModelRef {
profile: string;
displayName: string;
index: number;
selectorAlias: string;
selector: string;
}
interface DroidSettings {
model?: string;
customModels?: DroidCustomModelEntry[];
[key: string]: unknown;
}
@@ -55,13 +65,31 @@ interface DroidCustomModelEntry {
apiKey: string;
provider: string;
maxOutputTokens?: number;
extraArgs?: Record<string, unknown>;
extra_args?: Record<string, unknown>;
/** Internal alias used by CCS for lookup. Stored as the model's display name prefix. */
[key: string]: unknown;
}
const DROID_REASONING_OFF_VALUES = new Set(['off', 'none', 'disabled', '0']);
const DROID_ANTHROPIC_BUDGET_BY_EFFORT: Record<string, number> = {
minimal: 4000,
low: 4000,
medium: 12000,
high: 30000,
max: 50000,
xhigh: 64000,
auto: 30000,
};
function isSupportedProvider(value: string): value is DroidCustomModel['provider'] {
return value === 'anthropic' || value === 'openai' || value === 'generic-chat-completion-api';
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isDroidCustomModelEntry(value: unknown): value is DroidCustomModelEntry {
if (!value || typeof value !== 'object') return false;
const record = value as Record<string, unknown>;
@@ -97,6 +125,105 @@ function asModelEntry(value: unknown): DroidCustomModelEntry | null {
return isDroidCustomModelEntry(value) ? value : null;
}
function isReasoningOffValue(value: string | number): boolean {
if (typeof value === 'number') return value <= 0;
const normalized = value.trim().toLowerCase();
return DROID_REASONING_OFF_VALUES.has(normalized);
}
function toAnthropicBudget(value: string | number): number {
if (typeof value === 'number') {
return Math.max(1024, Math.floor(value));
}
const normalized = value.trim().toLowerCase();
if (/^\d+$/.test(normalized)) {
return Math.max(1024, Number.parseInt(normalized, 10));
}
return DROID_ANTHROPIC_BUDGET_BY_EFFORT[normalized] ?? DROID_ANTHROPIC_BUDGET_BY_EFFORT.high;
}
function toReasoningEffort(value: string | number): string {
if (typeof value === 'number') {
if (value <= 4000) return 'low';
if (value <= 12000) return 'medium';
if (value <= 30000) return 'high';
if (value <= 50000) return 'max';
return 'xhigh';
}
const normalized = value.trim().toLowerCase();
if (!normalized) return 'high';
return normalized;
}
function applyReasoningOverride(
entry: DroidCustomModelEntry,
provider: DroidCustomModel['provider'],
reasoningOverride: string | number
): void {
const extraArgsKey: 'extraArgs' | 'extra_args' = Object.prototype.hasOwnProperty.call(
entry,
'extra_args'
)
? 'extra_args'
: 'extraArgs';
const currentExtraArgs = entry[extraArgsKey];
const extraArgs = isObject(currentExtraArgs) ? { ...currentExtraArgs } : {};
// Normalize legacy aliases before writing provider-specific shape.
delete extraArgs.reasoningEffort;
if (provider === 'anthropic') {
delete extraArgs.reasoning;
delete extraArgs.reasoning_effort;
if (isReasoningOffValue(reasoningOverride)) {
delete extraArgs.thinking;
} else {
const thinking = isObject(extraArgs.thinking) ? { ...extraArgs.thinking } : {};
thinking.type = 'enabled';
thinking.budget_tokens = toAnthropicBudget(reasoningOverride);
delete thinking.budgetTokens;
extraArgs.thinking = thinking;
}
} else if (provider === 'openai') {
delete extraArgs.reasoning_effort;
delete extraArgs.thinking;
if (isReasoningOffValue(reasoningOverride)) {
delete extraArgs.reasoning;
} else {
const reasoning = isObject(extraArgs.reasoning) ? { ...extraArgs.reasoning } : {};
reasoning.effort = toReasoningEffort(reasoningOverride);
extraArgs.reasoning = reasoning;
}
} else {
delete extraArgs.reasoning;
delete extraArgs.thinking;
if (isReasoningOffValue(reasoningOverride)) {
delete extraArgs.reasoning_effort;
} else {
extraArgs.reasoning_effort = toReasoningEffort(reasoningOverride);
}
}
if (Object.keys(extraArgs).length === 0) {
delete entry.extraArgs;
delete entry.extra_args;
return;
}
entry[extraArgsKey] = extraArgs;
}
function buildSelectorAlias(displayName: string, index: number): string {
const normalizedDisplayName = displayName.trim().replace(/\s+/g, '-');
return `${normalizedDisplayName}-${index}`;
}
function normalizeCustomModels(value: unknown): DroidCustomModelEntry[] {
if (Array.isArray(value)) {
return value
@@ -302,37 +429,72 @@ function writeDroidSettings(settings: DroidSettings): void {
* Upsert a CCS-managed custom model entry.
* Acquires file lock to prevent concurrent write races.
*/
export async function upsertCcsModel(profile: string, model: DroidCustomModel): Promise<void> {
export async function upsertCcsModel(
profile: string,
model: DroidCustomModel
): Promise<DroidManagedModelRef> {
validateProfileName(profile);
ensureFactoryDir();
let release: (() => Promise<void>) | undefined;
let ref: DroidManagedModelRef | null = null;
try {
release = await acquireFactoryLock(10);
const settings = readDroidSettings();
settings.customModels = normalizeCustomModels(settings.customModels);
const entry: DroidCustomModelEntry = {
...model,
displayName: `CCS ${profile}`,
};
// Find existing current or legacy entry for this profile.
const idx = settings.customModels.findIndex(
(m) => parseManagedProfile(m.displayName) === profile
);
const { reasoningOverride, ...modelWithoutReasoning } = model;
const existingEntry = idx >= 0 ? settings.customModels[idx] : undefined;
const entry: DroidCustomModelEntry = {
...(existingEntry ?? {}),
...modelWithoutReasoning,
displayName: `CCS ${profile}`,
};
if (reasoningOverride !== undefined) {
applyReasoningOverride(entry, model.provider, reasoningOverride);
}
if (idx >= 0) {
settings.customModels[idx] = entry;
} else {
settings.customModels.push(entry);
}
const index = settings.customModels.findIndex(
(entry) => parseManagedProfile(entry.displayName) === profile
);
const safeIndex = index >= 0 ? index : 0;
const selectorAlias = buildSelectorAlias(entry.displayName, safeIndex);
const selector = `custom:${selectorAlias}`;
// Droid interactive mode uses settings.model for default model selection.
settings.model = selector;
writeDroidSettings(settings);
ref = {
profile,
displayName: entry.displayName,
index: safeIndex,
selectorAlias,
selector,
};
} finally {
if (release) await release();
}
return (
ref || {
profile,
displayName: `CCS ${profile}`,
index: 0,
selectorAlias: `CCS-${profile}-0`,
selector: `custom:CCS-${profile}-0`,
}
);
}
/**
+162
View File
@@ -0,0 +1,162 @@
/**
* Droid BYOK provider resolution helpers.
*
* Factory BYOK accepts exactly:
* - anthropic
* - openai
* - generic-chat-completion-api
*
* CCS stores provider hints in profile settings as CCS_DROID_PROVIDER and
* resolves a best-effort provider from base URL/model when the hint is absent.
*/
export type DroidProvider = 'anthropic' | 'openai' | 'generic-chat-completion-api';
const GENERIC_PROVIDER_ALIASES = new Set<string>([
'generic',
'generic-openai',
'generic-openai-api',
'generic-chat',
'generic-chat-completions',
'openai-compatible',
'chat-completions',
]);
const OPENAI_PROVIDER_ALIASES = new Set<string>(['openai-responses', 'openai-official']);
const ANTHROPIC_PROVIDER_ALIASES = new Set<string>(['anthropic-compatible']);
/**
* Normalize potentially messy provider input into a valid Factory provider.
*/
export function normalizeDroidProvider(provider: string | undefined | null): DroidProvider | null {
if (!provider) return null;
const normalized = provider.trim().toLowerCase();
if (!normalized) return null;
if (normalized === 'anthropic' || ANTHROPIC_PROVIDER_ALIASES.has(normalized)) {
return 'anthropic';
}
if (normalized === 'openai' || OPENAI_PROVIDER_ALIASES.has(normalized)) {
return 'openai';
}
if (normalized === 'generic-chat-completion-api' || GENERIC_PROVIDER_ALIASES.has(normalized)) {
return 'generic-chat-completion-api';
}
return null;
}
/**
* Infer provider primarily from base URL patterns used in BYOK configs.
*/
export function inferDroidProviderFromBaseUrl(
baseUrl: string | undefined | null
): DroidProvider | null {
if (!baseUrl) return null;
const raw = baseUrl.trim();
if (!raw) return null;
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return null;
}
const host = parsed.host.toLowerCase();
const pathname = parsed.pathname.toLowerCase();
const isLocalHost =
host.startsWith('localhost') || host.startsWith('127.0.0.1') || host.startsWith('[::1]');
if (
host.includes('api.openai.com') ||
host.includes('.openai.azure.com') ||
host.includes('.services.ai.azure.com')
) {
return 'openai';
}
if (host.includes('anthropic.com') || pathname.includes('/anthropic')) {
return 'anthropic';
}
if (
host.includes('openrouter.ai') ||
host.includes('api.groq.com') ||
host.includes('api.deepinfra.com') ||
host.includes('api.fireworks.ai') ||
host.includes('inference.baseten.co') ||
host.includes('huggingface.co') ||
host.includes('ollama.com') ||
pathname.includes('/openai') ||
pathname.includes('/chat/completions')
) {
return 'generic-chat-completion-api';
}
// Local OpenAI-compatible proxies are commonly exposed at /v1.
if (isLocalHost && (pathname === '/v1' || pathname.startsWith('/v1/'))) {
return 'generic-chat-completion-api';
}
return null;
}
/**
* Infer provider from model naming when URL does not provide a clear signal.
*/
export function inferDroidProviderFromModel(
model: string | undefined | null
): DroidProvider | null {
if (!model) return null;
const normalized = model.trim().toLowerCase();
if (!normalized) return null;
if (normalized.startsWith('claude-')) {
return 'anthropic';
}
if (
normalized.startsWith('gpt-') ||
normalized.startsWith('o1') ||
normalized.startsWith('o3') ||
normalized.startsWith('o4')
) {
return 'openai';
}
if (
normalized.startsWith('qwen') ||
normalized.startsWith('deepseek') ||
normalized.startsWith('kimi')
) {
return 'generic-chat-completion-api';
}
return null;
}
export interface DroidProviderResolveInput {
provider?: string | null;
baseUrl?: string | null;
model?: string | null;
}
/**
* Resolve a provider for Droid custom model entries.
*
* Precedence:
* 1) explicit provider hint (CCS_DROID_PROVIDER)
* 2) base URL inference
* 3) model inference
* 4) anthropic (backward-compatible default for legacy CCS profiles)
*/
export function resolveDroidProvider(input: DroidProviderResolveInput): DroidProvider {
const explicit = normalizeDroidProvider(input.provider);
if (explicit) return explicit;
const fromUrl = inferDroidProviderFromBaseUrl(input.baseUrl);
if (fromUrl) return fromUrl;
const fromModel = inferDroidProviderFromModel(input.model);
if (fromModel) return fromModel;
return 'anthropic';
}
+56
View File
@@ -0,0 +1,56 @@
import { parseThinkingOverride, type ThinkingFlag } from '../cliproxy/executor/thinking-arg-parser';
import { resolveRuntimeThinkingOverride } from '../cliproxy/executor/thinking-override-resolver';
export class DroidReasoningFlagError extends Error {
constructor(
message: string,
public readonly flag: ThinkingFlag
) {
super(message);
this.name = 'DroidReasoningFlagError';
}
}
export interface DroidReasoningRuntime {
argsWithoutReasoningFlags: string[];
reasoningOverride: string | number | undefined;
sourceFlag: ThinkingFlag | undefined;
sourceDisplay: string | undefined;
duplicateDisplays: string[];
}
function stripReasoningFlags(args: string[]): string[] {
return args.filter((arg, idx) => {
if (arg === '--thinking' || arg === '--effort') return false;
if (arg.startsWith('--thinking=')) return false;
if (arg.startsWith('--effort=')) return false;
if (args[idx - 1] === '--thinking' || args[idx - 1] === '--effort') return false;
return true;
});
}
export function resolveDroidReasoningRuntime(
args: string[],
envThinkingValue: string | undefined
): DroidReasoningRuntime {
const parseResult = parseThinkingOverride(args);
if (parseResult.error) {
throw new DroidReasoningFlagError(
`${parseResult.error.flag} requires a value`,
parseResult.error.flag
);
}
const { thinkingOverride, thinkingSource } = resolveRuntimeThinkingOverride(
parseResult.value,
envThinkingValue
);
return {
argsWithoutReasoningFlags: stripReasoningFlags(args),
reasoningOverride: thinkingOverride,
sourceFlag: thinkingSource === 'flag' ? parseResult.sourceFlag : undefined,
sourceDisplay: parseResult.sourceDisplay,
duplicateDisplays: parseResult.duplicateDisplays,
};
}
+2
View File
@@ -27,4 +27,6 @@ export {
pruneOrphanedModels,
} from './droid-config-manager';
export type { DroidCustomModel } from './droid-config-manager';
export { resolveDroidProvider, normalizeDroidProvider } from './droid-provider';
export type { DroidProvider } from './droid-provider';
export { resolveTargetType, stripTargetFlag } from './target-resolver';
+6
View File
@@ -23,6 +23,12 @@ export interface TargetCredentials {
apiKey: string;
model?: string;
provider?: 'anthropic' | 'openai' | 'generic-chat-completion-api';
/**
* Runtime reasoning/thinking override resolved from CCS flags/env
* (e.g. --thinking high, --effort xhigh, CCS_THINKING=medium).
* Targets may ignore this when unsupported.
*/
reasoningOverride?: string | number;
/** Additional env vars from profile resolution (websearch, hooks, etc.) */
envVars?: NodeJS.ProcessEnv;
}
+7 -3
View File
@@ -48,9 +48,13 @@ interface ParsedTargetFlags {
cleanedArgs: string[];
}
function isValidTarget(target: unknown): target is TargetType {
return typeof target === 'string' && VALID_TARGETS.has(target as TargetType);
}
function normalizeTargetValue(value: string): TargetType {
const normalized = value.toLowerCase();
if (VALID_TARGETS.has(normalized)) {
if (isValidTarget(normalized)) {
return normalized as TargetType;
}
@@ -120,8 +124,8 @@ export function resolveTargetType(
}
// 2. Check per-profile config
if (profileConfig?.target) {
return profileConfig.target;
if (profileConfig?.target !== undefined) {
return isValidTarget(profileConfig.target) ? profileConfig.target : 'claude';
}
// 3. Check argv[0] (busybox pattern)
+7
View File
@@ -4,6 +4,7 @@
*/
import type { CLIProxyProvider } from '../cliproxy/types';
import type { TargetType } from '../targets/target-adapter';
/**
* Profile configuration mapping
@@ -27,6 +28,8 @@ export interface CLIProxyVariantConfig {
account?: string;
/** Unique port for variant isolation (8318-8417) */
port?: number;
/** Target CLI to use for this variant (default: claude) */
target?: TargetType;
}
/**
@@ -44,6 +47,8 @@ export interface CLIProxyVariantsConfig {
export interface Config {
/** Settings-based profiles (GLM, Kimi, etc.) */
profiles: ProfilesConfig;
/** Per-profile CLI target overrides (legacy mode) */
profile_targets?: Record<string, TargetType>;
/** User-defined CLIProxy profile variants (optional) */
cliproxy?: CLIProxyVariantsConfig;
}
@@ -91,6 +96,8 @@ export interface ProfileMetadata {
context_mode?: 'isolated' | 'shared';
/** Context-sharing group when context_mode='shared' */
context_group?: string;
/** Shared continuity depth when context_mode='shared' */
continuity_mode?: 'standard' | 'deeper';
}
export interface ProfilesRegistry {
+124 -25
View File
@@ -559,6 +559,18 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
// ---------------------------------------------------------------------------
// MiniMax Models - Source: https://platform.minimax.io/docs/pricing/pay-as-you-go
// ---------------------------------------------------------------------------
'MiniMax-M2.5': {
inputPerMillion: 0.3,
outputPerMillion: 1.2,
cacheCreationPerMillion: 0.375,
cacheReadPerMillion: 0.03,
},
'MiniMax-M2.5-lightning': {
inputPerMillion: 0.6,
outputPerMillion: 2.4,
cacheCreationPerMillion: 0.375,
cacheReadPerMillion: 0.03,
},
'MiniMax-M2.1': {
inputPerMillion: 0.3,
outputPerMillion: 1.2,
@@ -566,7 +578,7 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
cacheReadPerMillion: 0.03,
},
'MiniMax-M2.1-lightning': {
inputPerMillion: 0.3,
inputPerMillion: 0.6,
outputPerMillion: 2.4,
cacheCreationPerMillion: 0.375,
cacheReadPerMillion: 0.03,
@@ -577,6 +589,51 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
cacheCreationPerMillion: 0.375,
cacheReadPerMillion: 0.03,
},
// ---------------------------------------------------------------------------
// Qwen Models - Source: https://www.alibabacloud.com/help/zh/model-studio/model-pricing
// ---------------------------------------------------------------------------
'qwen3-max': {
inputPerMillion: 1.2,
outputPerMillion: 6,
cacheCreationPerMillion: 1.2,
cacheReadPerMillion: 0.24,
},
'qwen3-max-2026-01-23': {
inputPerMillion: 1.2,
outputPerMillion: 6,
cacheCreationPerMillion: 1.2,
cacheReadPerMillion: 0.24,
},
'qwen3-max-preview': {
inputPerMillion: 1.2,
outputPerMillion: 6,
cacheCreationPerMillion: 1.2,
cacheReadPerMillion: 0.24,
},
'qwen3.5-plus': {
inputPerMillion: 0.4,
outputPerMillion: 2.4,
cacheCreationPerMillion: 0.4,
cacheReadPerMillion: 0.08,
},
'qwen3.5-flash': {
inputPerMillion: 0.1,
outputPerMillion: 0.4,
cacheCreationPerMillion: 0.1,
cacheReadPerMillion: 0.02,
},
'qwen3-coder-plus': {
inputPerMillion: 1,
outputPerMillion: 5,
cacheCreationPerMillion: 1,
cacheReadPerMillion: 0.2,
},
'qwen3-coder-flash': {
inputPerMillion: 0.3,
outputPerMillion: 1.5,
cacheCreationPerMillion: 0.3,
cacheReadPerMillion: 0.06,
},
// ---------------------------------------------------------------------------
// DeepSeek Models - Source: better-ccusage
@@ -629,6 +686,14 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
},
};
const MODEL_PRICING_ALIASES: Record<string, string> = {
// Keep catalog-only IDs on explicit priced equivalents.
'qwen3-coder': 'qwen3-coder-plus',
'qwen3-235b': 'qwen3-max',
'qwen3-vl-plus': 'qwen3.5-plus',
'qwen3-32b': 'qwen3.5-plus',
};
// Default pricing for unknown models
const UNKNOWN_MODEL_PRICING: ModelPricing = {
inputPerMillion: 3.0,
@@ -647,39 +712,76 @@ const UNKNOWN_MODEL_PRICING: ModelPricing = {
*/
function normalizeModelName(model: string): string {
// Remove provider prefixes (e.g., "anthropic/claude-..." -> "claude-...")
const normalized = model.toLowerCase().replace(/^[^/]+\//, '');
const normalized = model
.trim()
.toLowerCase()
.replace(/^[^/]+\//, '');
return normalized;
}
const NORMALIZED_PRICING_REGISTRY: Record<string, ModelPricing> = Object.entries(
PRICING_REGISTRY
).reduce<Record<string, ModelPricing>>((acc, [key, pricing]) => {
acc[normalizeModelName(key)] = pricing;
return acc;
}, {});
function getLookupCandidates(model: string): string[] {
const normalized = normalizeModelName(model);
const baseModel = normalized.split(':')[0];
return baseModel === normalized ? [normalized] : [normalized, baseModel];
}
function getDirectOrAliasPricing(model: string): ModelPricing | undefined {
const directPricing = PRICING_REGISTRY[model];
if (directPricing !== undefined) {
return directPricing;
}
for (const candidate of getLookupCandidates(model)) {
const normalizedPricing = NORMALIZED_PRICING_REGISTRY[candidate];
if (normalizedPricing !== undefined) {
return normalizedPricing;
}
const alias = MODEL_PRICING_ALIASES[candidate];
if (alias !== undefined) {
const aliasPricing = NORMALIZED_PRICING_REGISTRY[alias];
if (aliasPricing !== undefined) {
return aliasPricing;
}
}
}
return undefined;
}
/**
* Get pricing for a model with fuzzy matching fallback
* @param model - Model name (exact or with provider prefix)
* @returns ModelPricing for the model or fallback pricing
*/
export function getModelPricing(model: string): ModelPricing {
// Try exact match first
if (PRICING_REGISTRY[model]) {
return PRICING_REGISTRY[model];
const directOrAliasPricing = getDirectOrAliasPricing(model);
if (directOrAliasPricing !== undefined) {
return directOrAliasPricing;
}
// Try normalized match
const normalized = normalizeModelName(model);
if (PRICING_REGISTRY[normalized]) {
return PRICING_REGISTRY[normalized];
}
// Try suffix matching (e.g., "claude-sonnet-4-5" matches "*-claude-sonnet-4-5")
for (const [key, pricing] of Object.entries(PRICING_REGISTRY)) {
if (normalized.endsWith(key) || key.endsWith(normalized)) {
return pricing;
for (const candidate of getLookupCandidates(model)) {
// Try suffix matching (e.g., "claude-sonnet-4-5" matches "*-claude-sonnet-4-5")
for (const [key, pricing] of Object.entries(NORMALIZED_PRICING_REGISTRY)) {
if (candidate.endsWith(key) || key.endsWith(candidate)) {
return pricing;
}
}
}
// Try partial matching for model families
for (const [key, pricing] of Object.entries(PRICING_REGISTRY)) {
// Match by model family prefix
if (normalized.startsWith(key.split('-').slice(0, 2).join('-'))) {
return pricing;
// Try partial matching for model families
for (const [key, pricing] of Object.entries(NORMALIZED_PRICING_REGISTRY)) {
// Match by model family prefix
if (candidate.startsWith(key.split('-').slice(0, 2).join('-'))) {
return pricing;
}
}
}
@@ -716,8 +818,5 @@ export function getKnownModels(): string[] {
* Check if a model has custom pricing (not using fallback)
*/
export function hasCustomPricing(model: string): boolean {
return (
PRICING_REGISTRY[model] !== undefined ||
PRICING_REGISTRY[normalizeModelName(model)] !== undefined
);
return getDirectOrAliasPricing(model) !== undefined;
}
@@ -0,0 +1,48 @@
import type { CLIProxyProvider } from '../../cliproxy/types';
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
export interface MergedAccountEntry {
type: string;
created: string;
last_used: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string;
continuity_mode?: 'standard' | 'deeper';
context_inferred?: boolean;
continuity_inferred?: boolean;
provider?: string;
displayName?: string;
}
/** Parse CLIProxy account key format: "provider:accountId" */
export function parseCliproxyKey(
key: string
): { provider: CLIProxyProvider; accountId: string } | null {
let normalizedKey = key;
if (key.startsWith('cliproxy:')) {
normalizedKey = key.slice('cliproxy:'.length);
} else if (key.startsWith('cliproxy+')) {
normalizedKey = key.slice('cliproxy+'.length);
}
const colonIndex = normalizedKey.indexOf(':');
if (colonIndex === -1) return null;
const provider = normalizedKey.slice(0, colonIndex);
const accountId = normalizedKey.slice(colonIndex + 1);
if (!isCLIProxyProvider(provider) || !accountId) return null;
return { provider, accountId };
}
export function buildCliproxyAccountKey(
rawKey: string,
merged: Record<string, MergedAccountEntry>
): string | null {
const candidateKeys = [rawKey, `cliproxy:${rawKey}`, `cliproxy+${rawKey}`];
for (const key of candidateKeys) {
if (!merged[key]) {
return key;
}
}
return null;
}
+201 -41
View File
@@ -7,31 +7,36 @@
import { Router, Request, Response } from 'express';
import ProfileRegistry from '../../auth/profile-registry';
import InstanceManager from '../../management/instance-manager';
import { isUnifiedMode } from '../../config/unified-config-loader';
import {
getAllAccountsSummary,
setDefaultAccount as setCliproxyDefault,
getDefaultAccount as getCliproxyDefaultAccount,
removeAccount as removeCliproxyAccount,
bulkPauseAccounts,
bulkResumeAccounts,
soloAccount,
} from '../../cliproxy/account-manager';
import type { CLIProxyProvider } from '../../cliproxy/types';
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
import {
DEFAULT_ACCOUNT_CONTINUITY_MODE,
isValidContextGroupName,
normalizeContextGroupName,
resolveAccountContextPolicy,
} from '../../auth/account-context';
import {
buildCliproxyAccountKey,
parseCliproxyKey,
type MergedAccountEntry,
} from './account-route-helpers';
const router = Router();
const registry = new ProfileRegistry();
const instanceMgr = new InstanceManager();
/** Parse CLIProxy account key format: "provider:accountId" */
function parseCliproxyKey(key: string): { provider: CLIProxyProvider; accountId: string } | null {
const colonIndex = key.indexOf(':');
if (colonIndex === -1) return null;
const provider = key.slice(0, colonIndex);
const accountId = key.slice(colonIndex + 1);
if (!isCLIProxyProvider(provider) || !accountId) return null;
return { provider, accountId };
function hasAuthAccount(name: string): boolean {
return registry.hasAccountUnified(name) || registry.hasProfile(name);
}
/**
@@ -47,38 +52,45 @@ router.get('/', (_req: Request, res: Response): void => {
const cliproxyAccounts = getAllAccountsSummary();
// Merge profiles: unified config takes precedence
const merged: Record<
string,
{
type: string;
created: string;
last_used: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string;
provider?: string;
displayName?: string;
}
> = {};
const merged: Record<string, MergedAccountEntry> = {};
// Add legacy profiles first
for (const [name, meta] of Object.entries(legacyProfiles)) {
const contextPolicy = resolveAccountContextPolicy(meta);
const hasExplicitContextMode =
meta.context_mode === 'isolated' || meta.context_mode === 'shared';
const hasExplicitContinuityMode =
meta.continuity_mode === 'standard' || meta.continuity_mode === 'deeper';
merged[name] = {
type: meta.type || 'account',
created: meta.created,
last_used: meta.last_used || null,
context_mode: meta.context_mode,
context_group: meta.context_group,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group,
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : undefined,
context_inferred: !hasExplicitContextMode,
continuity_inferred:
contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined,
};
}
// Override with unified config accounts (takes precedence)
for (const [name, account] of Object.entries(unifiedAccounts)) {
const contextPolicy = resolveAccountContextPolicy(account);
const hasExplicitContextMode =
account.context_mode === 'isolated' || account.context_mode === 'shared';
const hasExplicitContinuityMode =
account.continuity_mode === 'standard' || account.continuity_mode === 'deeper';
merged[name] = {
type: 'account',
created: account.created,
last_used: account.last_used,
context_mode: account.context_mode,
context_group: account.context_group,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group,
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : undefined,
context_inferred: !hasExplicitContextMode,
continuity_inferred:
contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined,
};
}
@@ -91,7 +103,11 @@ router.get('/', (_req: Request, res: Response): void => {
}
// Use unique ID for key to prevent collisions between accounts with same nickname/email
const displayName = acct.nickname || acct.email || acct.id;
const key = `${provider}:${acct.id}`;
const rawKey = `${provider}:${acct.id}`;
const key = buildCliproxyAccountKey(rawKey, merged);
if (!key) {
continue;
}
merged[key] = {
type: 'cliproxy',
provider,
@@ -130,7 +146,7 @@ router.post('/default', (req: Request, res: Response): void => {
}
// Check if this is a CLIProxy account (format: "provider:accountId")
const cliproxyKey = parseCliproxyKey(name);
const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null;
if (cliproxyKey) {
const success = setCliproxyDefault(cliproxyKey.provider, cliproxyKey.accountId);
if (!success) {
@@ -154,6 +170,140 @@ router.post('/default', (req: Request, res: Response): void => {
}
});
/**
* PUT /api/accounts/:name/context - Update account context mode/group
*/
router.put('/:name/context', async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.params;
if (!name) {
res.status(400).json({ error: 'Missing account name' });
return;
}
// CLIProxy OAuth accounts do not support local account context metadata.
const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null;
if (cliproxyKey) {
res
.status(400)
.json({ error: `Context mode is not supported for CLIProxy account: ${name}` });
return;
}
const existsUnified = isUnifiedMode() && registry.hasAccountUnified(name);
const existsLegacy = registry.hasProfile(name);
if (!existsUnified && !existsLegacy) {
res.status(404).json({ error: `Account not found: ${name}` });
return;
}
const mode = req.body?.context_mode;
const rawGroup = req.body?.context_group;
const rawContinuityMode = req.body?.continuity_mode;
if (mode !== 'isolated' && mode !== 'shared') {
res.status(400).json({ error: 'Missing or invalid context_mode: expected isolated|shared' });
return;
}
if (mode !== 'shared' && rawGroup !== undefined) {
res
.status(400)
.json({ error: 'Invalid payload: context_group requires context_mode=shared' });
return;
}
if (mode !== 'shared' && rawContinuityMode !== undefined) {
res
.status(400)
.json({ error: 'Invalid payload: continuity_mode requires context_mode=shared' });
return;
}
let normalizedGroup: string | undefined;
let continuityMode: 'standard' | 'deeper' | undefined;
if (mode === 'shared') {
if (typeof rawGroup !== 'string' || rawGroup.trim().length === 0) {
res
.status(400)
.json({ error: 'Invalid payload: shared context_mode requires non-empty context_group' });
return;
}
normalizedGroup = normalizeContextGroupName(rawGroup);
if (!isValidContextGroupName(normalizedGroup)) {
res.status(400).json({
error:
'Invalid context_group. Use letters/numbers/dash/underscore, start with a letter, max 64 chars.',
});
return;
}
if (
rawContinuityMode !== undefined &&
rawContinuityMode !== 'standard' &&
rawContinuityMode !== 'deeper'
) {
res.status(400).json({
error: 'Invalid continuity_mode: expected standard|deeper',
});
return;
}
continuityMode = rawContinuityMode === 'deeper' ? 'deeper' : DEFAULT_ACCOUNT_CONTINUITY_MODE;
}
const metadata =
mode === 'shared'
? {
context_mode: 'shared' as const,
context_group: normalizedGroup,
continuity_mode: continuityMode,
}
: {
context_mode: 'isolated' as const,
};
const policy = resolveAccountContextPolicy(metadata);
const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined;
const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined;
try {
if (existsUnified) {
registry.updateAccountUnified(name, metadata);
}
if (existsLegacy) {
registry.updateProfile(name, metadata);
}
await instanceMgr.ensureInstance(name, policy);
} catch (error) {
if (existsUnified && previousUnified) {
registry.updateAccountUnified(name, previousUnified);
}
if (existsLegacy && previousLegacy) {
registry.updateProfile(name, previousLegacy);
}
throw error;
}
res.json({
name,
context_mode: policy.mode,
context_group: policy.group ?? null,
continuity_mode:
policy.mode === 'shared'
? (policy.continuityMode ?? DEFAULT_ACCOUNT_CONTINUITY_MODE)
: null,
context_inferred: false,
continuity_inferred: false,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* DELETE /api/accounts/reset-default - Reset to CCS default
*/
@@ -192,8 +342,16 @@ router.delete('/:name', (req: Request, res: Response): void => {
}
// Check if this is a CLIProxy account (format: "provider:accountId")
const cliproxyKey = parseCliproxyKey(name);
const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null;
if (cliproxyKey) {
const defaultCliproxyAccount = getCliproxyDefaultAccount(cliproxyKey.provider);
if (defaultCliproxyAccount?.id === cliproxyKey.accountId) {
res.status(400).json({
error: `Cannot delete default CLIProxy account: ${name}. Set another default first.`,
});
return;
}
const success = removeCliproxyAccount(cliproxyKey.provider, cliproxyKey.accountId);
if (!success) {
res.status(404).json({ error: `CLIProxy account not found: ${name}` });
@@ -203,22 +361,24 @@ router.delete('/:name', (req: Request, res: Response): void => {
return;
}
// Delete from appropriate config (unified and/or legacy)
let deleted = false;
if (isUnifiedMode() && registry.hasAccountUnified(name)) {
registry.removeAccountUnified(name);
deleted = true;
}
if (registry.hasProfile(name)) {
registry.deleteProfile(name);
deleted = true;
}
const existsUnified = isUnifiedMode() && registry.hasAccountUnified(name);
const existsLegacy = registry.hasProfile(name);
if (!deleted) {
if (!existsUnified && !existsLegacy) {
res.status(404).json({ error: `Account not found: ${name}` });
return;
}
// Match CLI remove ordering: delete instance first, metadata second.
instanceMgr.deleteInstance(name);
if (existsUnified) {
registry.removeAccountUnified(name);
}
if (existsLegacy) {
registry.deleteProfile(name);
}
res.json({ success: true, deleted: name });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
+72 -28
View File
@@ -46,12 +46,34 @@ import {
import { getOAuthFlowType } from '../../cliproxy/provider-capabilities';
import type { CLIProxyProvider } from '../../cliproxy/types';
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
import {
validateAntigravityRiskAcknowledgement,
isAntigravityResponsibilityBypassEnabled,
} from '../../cliproxy/antigravity-responsibility';
const router = Router();
// Valid providers list - derived from canonical CLIPROXY_PROFILES
const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
function logRouteError(context: string, error: unknown): void {
if (error instanceof Error) {
console.error(`[cliproxy-auth-routes] ${context}: ${error.message}`);
return;
}
console.error(`[cliproxy-auth-routes] ${context}: unknown error`);
}
function respondInternalError(
res: Response,
error: unknown,
fallbackMessage: string,
statusCode = 500
): void {
logRouteError(fallbackMessage, error);
res.status(statusCode).json({ error: fallbackMessage });
}
function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } {
if (raw === undefined || raw === null) {
return { method: normalizeKiroAuthMethod(), invalid: false };
@@ -163,12 +185,12 @@ router.get('/', async (_req: Request, res: Response): Promise<void> => {
const target = getProxyTarget();
if (target.isRemote) {
res.status(503).json({
error: (error as Error).message,
error: 'Failed to fetch remote auth status',
authStatus: [],
source: 'remote',
});
} else {
res.status(500).json({ error: (error as Error).message });
respondInternalError(res, error, 'Failed to fetch auth status.');
}
}
});
@@ -198,13 +220,12 @@ router.get('/accounts', async (_req: Request, res: Response): Promise<void> => {
const target = getProxyTarget();
if (target.isRemote) {
res.status(503).json({
error: (error as Error).message,
error: 'Failed to fetch remote account status',
accounts: [],
source: 'remote',
});
} else {
const message = error instanceof Error ? error.message : 'Failed to list accounts';
res.status(500).json({ error: message });
respondInternalError(res, error, 'Failed to list accounts.');
}
}
});
@@ -225,8 +246,7 @@ router.get('/accounts/:provider', (req: Request, res: Response): void => {
const accounts = getProviderAccounts(provider as CLIProxyProvider);
res.json({ provider, accounts });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to get provider accounts';
res.status(500).json({ error: message });
respondInternalError(res, error, 'Failed to get provider accounts.');
}
});
@@ -268,8 +288,7 @@ router.post('/accounts/:provider/default', (req: Request, res: Response): void =
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to set default account';
res.status(500).json({ error: message });
respondInternalError(res, error, 'Failed to set default account.');
}
});
@@ -305,8 +324,7 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to remove account';
res.status(500).json({ error: message });
respondInternalError(res, error, 'Failed to remove account.');
}
});
@@ -338,8 +356,7 @@ router.post('/accounts/:provider/:accountId/pause', (req: Request, res: Response
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to pause account';
res.status(500).json({ error: message });
respondInternalError(res, error, 'Failed to pause account.');
}
});
@@ -370,8 +387,7 @@ router.post('/accounts/:provider/:accountId/resume', (req: Request, res: Respons
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to resume account';
res.status(500).json({ error: message });
respondInternalError(res, error, 'Failed to resume account.');
}
});
@@ -381,13 +397,20 @@ router.post('/accounts/:provider/:accountId/resume', (req: Request, res: Respons
*/
router.post('/:provider/start', async (req: Request, res: Response): Promise<void> => {
const { provider } = req.params;
const {
nickname: nicknameRaw,
noIncognito: noIncognitoBody,
kiroMethod: kiroMethodRaw,
} = req.body;
const requestBody =
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
const noIncognitoBody =
typeof requestBody.noIncognito === 'boolean' ? requestBody.noIncognito : undefined;
const kiroMethodRaw = requestBody.kiroMethod;
const riskAcknowledgement = requestBody.riskAcknowledgement;
const target = getProxyTarget();
if (target.isRemote) {
res.status(501).json({ error: 'OAuth start flow not available in remote mode' });
return;
}
// Trim nickname for consistency with CLI (oauth-handler.ts trims input)
const nickname = typeof nicknameRaw === 'string' ? nicknameRaw.trim() : nicknameRaw;
const nickname = nicknameRaw?.trim();
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
// Validate provider
@@ -404,6 +427,17 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
return;
}
if (provider === 'agy' && !isAntigravityResponsibilityBypassEnabled()) {
const validation = validateAntigravityRiskAcknowledgement(riskAcknowledgement);
if (!validation.valid) {
res.status(400).json({
error: validation.error,
code: 'AGY_RISK_ACK_REQUIRED',
});
return;
}
}
// For kiro/ghcp: nickname is required
if (PROVIDERS_WITHOUT_EMAIL.includes(provider as CLIProxyProvider)) {
if (!nickname) {
@@ -451,6 +485,7 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
add: true, // Always add mode from UI
headless: false, // Force interactive mode
nickname: nickname || undefined,
acceptAgyRisk: provider === 'agy',
kiroMethod: provider === 'kiro' ? kiroMethod : undefined,
fromUI: true, // Enable project selection prompt in UI
noIncognito, // Kiro: use normal browser if enabled
@@ -471,7 +506,7 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
res.status(400).json({ error: 'Authentication failed or was cancelled' });
}
} catch (error) {
res.status(500).json({ error: (error as Error).message });
respondInternalError(res, error, 'Failed to start OAuth flow.');
}
});
@@ -584,7 +619,7 @@ router.post('/kiro/import', async (_req: Request, res: Response): Promise<void>
});
}
} catch (error) {
res.status(500).json({ error: (error as Error).message });
respondInternalError(res, error, 'Failed to import Kiro token.');
}
});
@@ -596,7 +631,7 @@ router.post('/kiro/import', async (_req: Request, res: Response): Promise<void>
*/
router.post('/:provider/start-url', async (req: Request, res: Response): Promise<void> => {
const { provider } = req.params;
const { kiroMethod: kiroMethodRaw } = req.body ?? {};
const { kiroMethod: kiroMethodRaw, riskAcknowledgement } = req.body ?? {};
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
// Check remote mode
@@ -620,6 +655,17 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
return;
}
if (provider === 'agy' && !isAntigravityResponsibilityBypassEnabled()) {
const validation = validateAntigravityRiskAcknowledgement(riskAcknowledgement);
if (!validation.valid) {
res.status(400).json({
error: validation.error,
code: 'AGY_RISK_ACK_REQUIRED',
});
return;
}
}
const unsupportedReason = getStartUrlUnsupportedReason(provider as CLIProxyProvider, {
kiroMethod: provider === 'kiro' ? kiroMethod : undefined,
});
@@ -672,8 +718,7 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
method: data.method || null,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to start OAuth';
res.status(503).json({ error: `CLIProxyAPI not reachable: ${message}` });
respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503);
}
});
@@ -787,8 +832,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to submit callback';
res.status(503).json({ error: `CLIProxyAPI not reachable: ${message}` });
respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503);
}
});
+94
View File
@@ -18,9 +18,88 @@ import {
getBackupDirectories,
} from '../../config/migration-manager';
import { isUnifiedConfig } from '../../config/unified-config-types';
import {
DEFAULT_ACCOUNT_CONTINUITY_MODE,
isValidContextGroupName,
normalizeContextGroupName,
} from '../../auth/account-context';
const router = Router();
function validateAndNormalizeAccountContextMetadata(config: unknown): string | null {
if (typeof config !== 'object' || config === null) {
return 'Invalid config payload';
}
const candidate = config as Record<string, unknown>;
const accounts = candidate.accounts;
if (accounts === undefined) {
return null;
}
if (typeof accounts !== 'object' || accounts === null || Array.isArray(accounts)) {
return 'Invalid config.accounts: expected object';
}
for (const [accountName, accountValue] of Object.entries(accounts as Record<string, unknown>)) {
if (typeof accountValue !== 'object' || accountValue === null || Array.isArray(accountValue)) {
return `Invalid config.accounts.${accountName}: expected object`;
}
const account = accountValue as Record<string, unknown>;
const mode = account.context_mode;
const group = account.context_group;
const continuity = account.continuity_mode;
if (mode !== undefined && mode !== 'isolated' && mode !== 'shared') {
return `Invalid config.accounts.${accountName}.context_mode: expected isolated|shared`;
}
if (group !== undefined && typeof group !== 'string') {
return `Invalid config.accounts.${accountName}.context_group: expected string`;
}
if (continuity !== undefined && continuity !== 'standard' && continuity !== 'deeper') {
return `Invalid config.accounts.${accountName}.continuity_mode: expected standard|deeper`;
}
if (mode !== 'shared' && group !== undefined) {
return `Invalid config.accounts.${accountName}: context_group requires context_mode=shared`;
}
if (mode !== 'shared' && continuity !== undefined) {
return `Invalid config.accounts.${accountName}: continuity_mode requires context_mode=shared`;
}
if (mode === 'shared' && typeof group === 'string' && group.trim().length > 0) {
const normalizedGroup = normalizeContextGroupName(group);
if (!isValidContextGroupName(normalizedGroup)) {
return `Invalid config.accounts.${accountName}.context_group`;
}
account.context_group = normalizedGroup;
}
if (mode === 'shared') {
account.continuity_mode =
continuity === 'deeper' ? 'deeper' : DEFAULT_ACCOUNT_CONTINUITY_MODE;
}
if (mode === 'shared' && typeof group === 'string' && group.trim().length === 0) {
return `Invalid config.accounts.${accountName}.context_group: shared mode requires a non-empty value`;
}
if (mode === 'isolated' && group !== undefined) {
delete account.context_group;
}
if (mode === 'isolated' && continuity !== undefined) {
delete account.continuity_mode;
}
}
return null;
}
/**
* GET /api/config/format - Return current config format and migration status
*/
@@ -82,6 +161,12 @@ router.put('/', (req: Request, res: Response): void => {
return;
}
const accountContextError = validateAndNormalizeAccountContextMetadata(config);
if (accountContextError) {
res.status(400).json({ error: accountContextError });
return;
}
try {
saveUnifiedConfig(config);
res.json({ success: true });
@@ -96,6 +181,15 @@ router.put('/', (req: Request, res: Response): void => {
router.post('/migrate', async (req: Request, res: Response): Promise<void> => {
try {
const dryRun = req.query.dryRun === 'true';
if (!needsMigration()) {
res.json({
success: true,
migratedFiles: [],
warnings: [],
alreadyMigrated: true,
});
return;
}
const result = await migrate(dryRun);
res.json(result);
} catch (error) {
+71
View File
@@ -0,0 +1,71 @@
import type { Request, Response } from 'express';
import { Router } from 'express';
import {
DroidRawSettingsConflictError,
DroidRawSettingsValidationError,
getDroidDashboardDiagnostics,
getDroidRawSettings,
saveDroidRawSettings,
} from '../services/droid-dashboard-service';
const router = Router();
/**
* GET /api/droid/diagnostics
* Dashboard-ready Droid installation + BYOK configuration diagnostics.
*/
router.get('/diagnostics', async (_req: Request, res: Response): Promise<void> => {
try {
res.json(await getDroidDashboardDiagnostics());
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/droid/settings/raw
* Raw ~/.factory/settings.json payload for editor.
*/
router.get('/settings/raw', async (_req: Request, res: Response): Promise<void> => {
try {
res.json(await getDroidRawSettings());
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* PUT /api/droid/settings/raw
* Save raw ~/.factory/settings.json payload from dashboard editor.
*/
router.put('/settings/raw', async (req: Request, res: Response): Promise<void> => {
try {
const { rawText, expectedMtime } = req.body ?? {};
if (typeof rawText !== 'string') {
res.status(400).json({ error: 'rawText must be a string.' });
return;
}
if (
expectedMtime !== undefined &&
(typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime))
) {
res.status(400).json({ error: 'expectedMtime must be a finite number when provided.' });
return;
}
res.json(await saveDroidRawSettings({ rawText, expectedMtime }));
} catch (error) {
if (error instanceof DroidRawSettingsValidationError) {
res.status(400).json({ error: error.message });
return;
}
if (error instanceof DroidRawSettingsConflictError) {
res.status(409).json({ error: error.message, mtime: error.mtime });
return;
}
res.status(500).json({ error: (error as Error).message });
}
});
export default router;
+4
View File
@@ -21,6 +21,7 @@ import cliproxyStatsRoutes from './cliproxy-stats-routes';
import cliproxySyncRoutes from './cliproxy-sync-routes';
import copilotRoutes from './copilot-routes';
import cursorRoutes from './cursor-routes';
import droidRoutes from './droid-routes';
import miscRoutes from './misc-routes';
import cliproxyServerRoutes from './proxy-routes';
import authRoutes from './auth-routes';
@@ -67,6 +68,9 @@ apiRoutes.use('/copilot', copilotRoutes);
// ==================== Cursor ====================
apiRoutes.use('/cursor', cursorRoutes);
// ==================== Droid ====================
apiRoutes.use('/droid', droidRoutes);
// ==================== CLIProxy Server Settings ====================
apiRoutes.use('/cliproxy-server', cliproxyServerRoutes);
+113 -12
View File
@@ -7,12 +7,35 @@
import { Router, Request, Response } from 'express';
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
import { createApiProfile, removeApiProfile } from '../../api/services/profile-writer';
import {
createApiProfile,
removeApiProfile,
updateApiProfileTarget,
} from '../../api/services/profile-writer';
import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader';
import type { TargetType } from '../../targets/target-adapter';
import { normalizeDroidProvider } from '../../targets/droid-provider';
import { updateSettingsFile } from './route-helpers';
const router = Router();
export function parseTarget(rawTarget: unknown): TargetType | null {
if (rawTarget === undefined || rawTarget === null || rawTarget === '') {
return null;
}
if (typeof rawTarget !== 'string') {
return null;
}
const normalized = rawTarget.trim().toLowerCase();
if (normalized === 'claude' || normalized === 'droid') {
return normalized;
}
return null;
}
// ==================== Profile CRUD ====================
/**
@@ -26,6 +49,7 @@ router.get('/', (_req: Request, res: Response): void => {
name: p.name,
settingsPath: p.settingsPath,
configured: p.isConfigured,
target: p.target,
}));
res.json({ profiles });
} catch (error) {
@@ -37,7 +61,21 @@ router.get('/', (_req: Request, res: Response): void => {
* POST /api/profiles - Create new profile
*/
router.post('/', (req: Request, res: Response): void => {
const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body;
const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body;
const providerHint = req.body?.droidProvider ?? req.body?.provider;
const parsedProvider = normalizeDroidProvider(providerHint);
const parsedTarget = parseTarget(target);
if (target !== undefined && parsedTarget === null) {
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
return;
}
if (providerHint !== undefined && parsedProvider === null) {
res.status(400).json({
error: 'Invalid droid provider. Expected: anthropic, openai, or generic-chat-completion-api',
});
return;
}
if (!name || !baseUrl || !apiKey) {
res.status(400).json({ error: 'Missing required fields: name, baseUrl, apiKey' });
@@ -60,19 +98,30 @@ router.post('/', (req: Request, res: Response): void => {
}
// Create profile using unified-config-aware service
const result = createApiProfile(name, baseUrl, apiKey, {
default: model || '',
opus: opusModel || model || '',
sonnet: sonnetModel || model || '',
haiku: haikuModel || model || '',
});
const result = createApiProfile(
name,
baseUrl,
apiKey,
{
default: model || '',
opus: opusModel || model || '',
sonnet: sonnetModel || model || '',
haiku: haikuModel || model || '',
},
parsedTarget || 'claude',
parsedProvider || undefined
);
if (!result.success) {
res.status(500).json({ error: result.error || 'Failed to create profile' });
return;
}
res.status(201).json({ name, settingsPath: result.settingsFile });
res.status(201).json({
name,
settingsPath: result.settingsFile,
target: parsedTarget || 'claude',
});
});
/**
@@ -80,7 +129,21 @@ router.post('/', (req: Request, res: Response): void => {
*/
router.put('/:name', (req: Request, res: Response): void => {
const { name } = req.params;
const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body;
const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body;
const providerHint = req.body?.droidProvider ?? req.body?.provider;
const parsedProvider = normalizeDroidProvider(providerHint);
const parsedTarget = parseTarget(target);
if (target !== undefined && parsedTarget === null) {
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
return;
}
if (providerHint !== undefined && parsedProvider === null) {
res.status(400).json({
error: 'Invalid droid provider. Expected: anthropic, openai, or generic-chat-completion-api',
});
return;
}
// Check if profile exists (uses unified config when available)
if (!apiProfileExists(name)) {
@@ -99,8 +162,46 @@ router.put('/:name', (req: Request, res: Response): void => {
}
try {
updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel });
res.json({ name, updated: true });
const hasSettingsUpdates =
baseUrl !== undefined ||
apiKey !== undefined ||
model !== undefined ||
opusModel !== undefined ||
sonnetModel !== undefined ||
haikuModel !== undefined ||
providerHint !== undefined;
const hasTargetUpdate = target !== undefined;
if (!hasSettingsUpdates && !hasTargetUpdate) {
res.status(400).json({ error: 'No updates provided' });
return;
}
if (hasSettingsUpdates) {
updateSettingsFile(name, {
baseUrl,
apiKey,
model,
opusModel,
sonnetModel,
haikuModel,
provider: parsedProvider || undefined,
});
}
if (hasTargetUpdate && parsedTarget) {
const targetUpdate = updateApiProfileTarget(name, parsedTarget);
if (!targetUpdate.success) {
res.status(500).json({ error: targetUpdate.error || 'Failed to update target' });
return;
}
}
res.json({
name,
updated: true,
...(hasTargetUpdate && parsedTarget && { target: parsedTarget }),
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
+25 -1
View File
@@ -7,6 +7,7 @@ import * as path from 'path';
import { getCcsDir, getConfigPath, loadConfigSafe, loadSettings } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers';
import { getClaudeSettingsPath } from '../../utils/claude-config-path';
import { resolveDroidProvider } from '../../targets/droid-provider';
import type { Config, Settings } from '../../types/config';
/** Model mapping for API profiles */
@@ -64,10 +65,16 @@ export function createSettingsFile(
name: string,
baseUrl: string,
apiKey: string,
models: ModelMapping = {}
models: ModelMapping = {},
provider?: string
): string {
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
const { model, opusModel, sonnetModel, haikuModel } = models;
const droidProvider = resolveDroidProvider({
provider,
baseUrl,
model,
});
const settings: Settings = {
env: {
@@ -77,6 +84,7 @@ export function createSettingsFile(
...(opusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel }),
...(sonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel }),
...(haikuModel && { ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel }),
CCS_DROID_PROVIDER: droidProvider,
},
};
@@ -96,6 +104,7 @@ export function updateSettingsFile(
opusModel?: string;
sonnetModel?: string;
haikuModel?: string;
provider?: string;
}
): void {
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
@@ -153,6 +162,21 @@ export function updateSettingsFile(
}
}
if (
updates.provider !== undefined ||
updates.baseUrl !== undefined ||
updates.model !== undefined ||
settings.env?.CCS_DROID_PROVIDER
) {
settings.env = settings.env || {};
const resolvedProvider = resolveDroidProvider({
provider: updates.provider ?? settings.env.CCS_DROID_PROVIDER,
baseUrl: updates.baseUrl ?? settings.env.ANTHROPIC_BASE_URL,
model: updates.model ?? settings.env.ANTHROPIC_MODEL,
});
settings.env.CCS_DROID_PROVIDER = resolvedProvider;
}
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
}
+135 -12
View File
@@ -19,6 +19,11 @@ import {
} from '../../cliproxy';
import { regenerateConfig } from '../../cliproxy/config-generator';
import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
import {
getDashboardAuthConfig,
loadOrCreateUnifiedConfig,
mutateUnifiedConfig,
} from '../../config/unified-config-loader';
import type { Settings } from '../../types/config';
const router = Router();
@@ -31,6 +36,73 @@ const MODEL_ENV_KEYS = [
] as const;
const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const;
function logRouteError(context: string, error: unknown): void {
if (error instanceof Error) {
console.error(`[settings-routes] ${context}: ${error.message}`);
return;
}
console.error(`[settings-routes] ${context}: unknown error`);
}
function respondInternalError(
res: Response,
error: unknown,
fallbackMessage: string,
statusCode = 500
): void {
logRouteError(fallbackMessage, error);
res.status(statusCode).json({ error: fallbackMessage });
}
function isLoopbackAddress(value: string | undefined): boolean {
if (!value) return false;
const normalized = value.trim().replace(/^\[|\]$/g, '');
return (
normalized === '::1' ||
normalized === '127.0.0.1' ||
normalized.startsWith('127.') ||
normalized === '::ffff:127.0.0.1' ||
normalized.startsWith('::ffff:127.')
);
}
function requireSensitiveLocalAccess(req: Request, res: Response): boolean {
const dashboardAuth = getDashboardAuthConfig();
if (dashboardAuth.enabled) {
return true;
}
const forwarded = req.headers['x-forwarded-for'];
const firstForwarded =
typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : undefined;
const candidateAddress = firstForwarded || req.socket.remoteAddress || req.ip;
if (isLoopbackAddress(candidateAddress)) {
return true;
}
res.status(403).json({
error: 'Sensitive settings endpoints require localhost access when dashboard auth is disabled.',
});
return false;
}
function classifyConfigSaveFailure(error: unknown): { statusCode: number; message: string } {
const message = error instanceof Error ? error.message.toLowerCase() : '';
if (message.includes('failed to acquire config lock')) {
return { statusCode: 409, message: 'Configuration is busy. Retry in a moment.' };
}
if (message.includes('eacces') || message.includes('eperm') || message.includes('permission')) {
return { statusCode: 403, message: 'Insufficient permission to update configuration.' };
}
if (message.includes('enospc') || message.includes('no space left')) {
return { statusCode: 507, message: 'Insufficient disk space to update configuration.' };
}
return { statusCode: 500, message: 'Failed to update Antigravity power user mode.' };
}
/**
* Helper: Resolve settings path for profile or variant
* Variants have settings paths in config, regular profiles use {name}.settings.json
@@ -144,7 +216,7 @@ router.get('/:profile', (req: Request, res: Response): void => {
path: settingsPath,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
respondInternalError(res, error, 'Internal server error.');
}
});
@@ -171,7 +243,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => {
path: settingsPath,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
respondInternalError(res, error, 'Internal server error.');
}
});
@@ -264,7 +336,7 @@ router.put('/:profile', (req: Request, res: Response): void => {
}),
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
respondInternalError(res, error, 'Internal server error.');
}
});
@@ -286,7 +358,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => {
const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath));
res.json({ presets: settings.presets || [] });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
respondInternalError(res, error, 'Internal server error.');
}
});
@@ -345,7 +417,7 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
res.status(201).json({ preset });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
respondInternalError(res, error, 'Internal server error.');
}
});
@@ -377,12 +449,61 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void =>
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
respondInternalError(res, error, 'Internal server error.');
}
});
// ==================== Auth Tokens ====================
/**
* GET /api/settings/auth/antigravity-risk - Get AGY responsibility bypass setting
*/
router.get('/auth/antigravity-risk', (req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(req, res)) return;
try {
const config = loadOrCreateUnifiedConfig();
res.json({
antigravityAckBypass: config.cliproxy?.safety?.antigravity_ack_bypass === true,
});
} catch (error) {
respondInternalError(res, error, 'Failed to load Antigravity power user mode.');
}
});
/**
* PUT /api/settings/auth/antigravity-risk - Update AGY responsibility bypass setting
*/
router.put('/auth/antigravity-risk', (req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(req, res)) return;
try {
const body = req.body as { antigravityAckBypass?: unknown } | null | undefined;
const antigravityAckBypass =
body && typeof body === 'object' ? body.antigravityAckBypass : undefined;
if (typeof antigravityAckBypass !== 'boolean') {
res.status(400).json({ error: 'antigravityAckBypass must be a boolean' });
return;
}
const updatedConfig = mutateUnifiedConfig((config) => {
config.cliproxy.safety = {
...(config.cliproxy.safety ?? {}),
antigravity_ack_bypass: antigravityAckBypass,
};
});
res.json({
success: true,
antigravityAckBypass: updatedConfig.cliproxy?.safety?.antigravity_ack_bypass === true,
});
} catch (error) {
const classified = classifyConfigSaveFailure(error);
respondInternalError(res, error, classified.message, classified.statusCode);
}
});
/**
* GET /api/settings/auth/tokens - Get current auth token status (masked)
*/
@@ -401,7 +522,7 @@ router.get('/auth/tokens', (_req: Request, res: Response): void => {
},
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
respondInternalError(res, error, 'Internal server error.');
}
});
@@ -409,7 +530,9 @@ router.get('/auth/tokens', (_req: Request, res: Response): void => {
* GET /api/settings/auth/tokens/raw - Get current auth tokens unmasked
* NOTE: Sensitive endpoint - no caching, localhost only
*/
router.get('/auth/tokens/raw', (_req: Request, res: Response): void => {
router.get('/auth/tokens/raw', (req: Request, res: Response): void => {
if (!requireSensitiveLocalAccess(req, res)) return;
try {
// Prevent caching of sensitive data
res.setHeader('Cache-Control', 'no-store');
@@ -427,7 +550,7 @@ router.get('/auth/tokens/raw', (_req: Request, res: Response): void => {
},
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
respondInternalError(res, error, 'Failed to load raw auth tokens.');
}
});
@@ -463,7 +586,7 @@ router.put('/auth/tokens', (req: Request, res: Response): void => {
message: 'Restart CLIProxy to apply changes',
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
respondInternalError(res, error, 'Internal server error.');
}
});
@@ -487,7 +610,7 @@ router.post('/auth/tokens/regenerate-secret', (_req: Request, res: Response): vo
message: 'Restart CLIProxy to apply changes',
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
respondInternalError(res, error, 'Internal server error.');
}
});
@@ -515,7 +638,7 @@ router.post('/auth/tokens/reset', (_req: Request, res: Response): void => {
message: 'Tokens reset to defaults. Restart CLIProxy to apply.',
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
respondInternalError(res, error, 'Internal server error.');
}
});
+61 -6
View File
@@ -7,6 +7,7 @@
import { Router, Request, Response } from 'express';
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
import type { CLIProxyProvider } from '../../cliproxy/types';
import type { TargetType } from '../../targets/target-adapter';
import {
createVariant,
removeVariant,
@@ -23,6 +24,23 @@ import {
const router = Router();
export function parseTarget(rawTarget: unknown): TargetType | null {
if (rawTarget === undefined || rawTarget === null || rawTarget === '') {
return null;
}
if (typeof rawTarget !== 'string') {
return null;
}
const normalized = rawTarget.trim().toLowerCase();
if (normalized === 'claude' || normalized === 'droid') {
return normalized;
}
return null;
}
/**
* GET /api/cliproxy - List cliproxy variants
* Uses variant-service for consistent behavior with CLI
@@ -36,6 +54,7 @@ router.get('/', (_req: Request, res: Response) => {
account: variant.account || 'default',
port: variant.port, // Include port for port isolation
model: variant.model,
target: variant.target || 'claude',
type: variant.type,
default_tier: variant.default_tier,
tiers: variant.tiers,
@@ -50,6 +69,12 @@ router.get('/', (_req: Request, res: Response) => {
*/
router.post('/', (req: Request, res: Response): void => {
const { name, provider, model, account, type, default_tier, tiers } = req.body;
const parsedTarget = parseTarget(req.body.target);
if (req.body.target !== undefined && parsedTarget === null) {
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
return;
}
if (!name) {
res.status(400).json({ error: 'Missing required field: name' });
@@ -91,7 +116,12 @@ router.post('/', (req: Request, res: Response): void => {
let result;
try {
result = createCompositeVariant({ name, defaultTier: default_tier, tiers });
result = createCompositeVariant({
name,
defaultTier: default_tier,
target: parsedTarget || 'claude',
tiers,
});
} catch (error) {
res.status(400).json({ error: (error as Error).message });
return;
@@ -109,6 +139,7 @@ router.post('/', (req: Request, res: Response): void => {
tiers,
settings: result.settingsPath,
port: result.variant?.port,
target: result.variant?.target || 'claude',
});
return;
}
@@ -126,7 +157,13 @@ router.post('/', (req: Request, res: Response): void => {
}
// Use variant-service for proper port allocation
const result = createVariant(name, provider as CLIProxyProvider, model, account);
const result = createVariant(
name,
provider as CLIProxyProvider,
model,
account,
parsedTarget || 'claude'
);
if (!result.success) {
res.status(409).json({ error: result.error });
@@ -140,6 +177,7 @@ router.post('/', (req: Request, res: Response): void => {
account: account || 'default',
port: result.variant?.port,
model: result.variant?.model,
target: result.variant?.target || 'claude',
});
});
@@ -154,6 +192,12 @@ router.put('/:name', (req: Request, res: Response): void => {
try {
const { name } = req.params;
const { provider, account, model, default_tier, tiers } = req.body;
const parsedTarget = parseTarget(req.body.target);
if (req.body.target !== undefined && parsedTarget === null) {
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
return;
}
// Check if variant is composite - use updateCompositeVariant if so
const variants = listVariants();
@@ -165,8 +209,8 @@ router.put('/:name', (req: Request, res: Response): void => {
}
if (existing.type === 'composite') {
if (!default_tier && !tiers) {
res.status(400).json({ error: 'Must provide at least default_tier or tiers' });
if (!default_tier && !tiers && req.body.target === undefined) {
res.status(400).json({ error: 'Must provide at least default_tier, tiers, or target' });
return;
}
@@ -189,7 +233,11 @@ router.put('/:name', (req: Request, res: Response): void => {
}
}
const result = updateCompositeVariant(name, { defaultTier: default_tier, tiers });
const result = updateCompositeVariant(name, {
defaultTier: default_tier,
tiers,
target: req.body.target !== undefined && parsedTarget ? parsedTarget : undefined,
});
if (!result.success) {
const status = result.error?.includes('not found') ? 404 : 400;
@@ -207,13 +255,19 @@ router.put('/:name', (req: Request, res: Response): void => {
tiers: persisted?.tiers,
settings: persisted?.settings,
port: persisted?.port,
target: persisted?.target || 'claude',
updated: true,
});
return;
}
// Use variant-service for proper update handling (single provider)
const result = updateVariant(name, { provider, account, model });
const result = updateVariant(name, {
provider,
account,
model,
target: req.body.target !== undefined && parsedTarget ? parsedTarget : undefined,
});
if (!result.success) {
const status = result.error?.includes('not found') ? 404 : 400;
@@ -227,6 +281,7 @@ router.put('/:name', (req: Request, res: Response): void => {
account: result.variant?.account || 'default',
settings: result.variant?.settings,
port: result.variant?.port,
target: result.variant?.target || 'claude',
updated: true,
});
} catch (error) {
@@ -0,0 +1,107 @@
export interface CompatibleCliDocLink {
id: string;
label: string;
url: string;
category: 'overview' | 'configuration' | 'byok' | 'reference';
source: 'factory' | 'provider';
description: string;
}
export interface CompatibleCliProviderDocLink {
provider: string;
label: string;
apiFormat: string;
url: string;
}
export interface CompatibleCliDocsReference {
providerValues: string[];
settingsHierarchy: string[];
notes: string[];
links: CompatibleCliDocLink[];
providerDocs: CompatibleCliProviderDocLink[];
}
interface CompatibleCliDocsRegistryEntry {
cliId: string;
displayName: string;
docsReference: CompatibleCliDocsReference;
}
const COMPATIBLE_CLI_DOCS_REGISTRY: Record<string, CompatibleCliDocsRegistryEntry> = {
droid: {
cliId: 'droid',
displayName: 'Droid CLI',
docsReference: {
providerValues: ['anthropic', 'openai', 'generic-chat-completion-api'],
settingsHierarchy: [
'project-level config',
'user-level config',
'home-level config',
'CLI flags and env vars',
],
notes: [
'BYOK custom models are read from ~/.factory/settings.json customModels[]',
'Legacy key style (custom_models, model_display_name, base_url, api_key, max_tokens) remains in circulation',
'Factory docs mention legacy support for ~/.factory/config.json',
'Interactive model selection uses settings.model (custom:<alias>)',
'Provider-specific reasoning keys in extraArgs: generic-chat-completion-api => reasoning_effort, openai => reasoning.effort, anthropic => thinking.{type,budget_tokens}',
'droid exec supports --model for one-off execution mode',
],
links: [
{
id: 'droid-cli-overview',
label: 'Droid CLI Overview',
url: 'https://docs.factory.ai/cli/',
category: 'overview',
source: 'factory',
description: 'Primary entry docs for setup, auth, and core CLI usage.',
},
{
id: 'droid-byok-overview',
label: 'BYOK Overview',
url: 'https://docs.factory.ai/cli/byok/overview/',
category: 'byok',
source: 'factory',
description: 'BYOK model/provider shape, provider values, and migration notes.',
},
{
id: 'droid-settings-reference',
label: 'settings.json Reference',
url: 'https://docs.factory.ai/cli/configuration/settings/',
category: 'configuration',
source: 'factory',
description: 'Supported settings keys, defaults, and allowed values.',
},
],
providerDocs: [
{
provider: 'anthropic',
label: 'Anthropic Messages API',
apiFormat: 'Messages API',
url: 'https://docs.anthropic.com/en/api/messages',
},
{
provider: 'openai',
label: 'OpenAI Responses API',
apiFormat: 'Responses API',
url: 'https://platform.openai.com/docs/api-reference/responses',
},
{
provider: 'generic-chat-completion-api',
label: 'OpenAI Chat Completions Spec',
apiFormat: 'Chat Completions API',
url: 'https://platform.openai.com/docs/api-reference/chat',
},
],
},
},
};
export function getCompatibleCliDocsReference(cliId: string): CompatibleCliDocsReference {
const entry = COMPATIBLE_CLI_DOCS_REGISTRY[cliId];
if (!entry) {
throw new Error(`Unsupported compatible CLI docs reference: ${cliId}`);
}
return entry.docsReference;
}
@@ -0,0 +1,231 @@
import { promises as fs } from 'fs';
import * as path from 'path';
export interface JsonFileDiagnostics {
label: string;
path: string;
resolvedPath: string;
exists: boolean;
isSymlink: boolean;
isRegularFile: boolean;
sizeBytes: number | null;
mtimeMs: number | null;
parseError: string | null;
readError: string | null;
}
export interface JsonFileProbe {
diagnostics: JsonFileDiagnostics;
json: Record<string, unknown> | null;
rawText: string;
}
interface WriteJsonObjectFileInput {
filePath: string;
rawText: string;
expectedMtime?: number;
fileLabel?: string;
dirMode?: number;
fileMode?: number;
}
interface WriteJsonObjectFileResult {
mtime: number;
}
export class JsonFileValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'JsonFileValidationError';
}
}
export class JsonFileConflictError extends Error {
readonly code = 'CONFLICT';
readonly mtime: number;
constructor(message: string, mtime: number) {
super(message);
this.name = 'JsonFileConflictError';
this.mtime = mtime;
}
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
async function statPath(filePath: string): Promise<import('fs').Stats | null> {
try {
return await fs.lstat(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
throw error;
}
}
export async function probeJsonObjectFile(
filePath: string,
label: string,
displayPath: string
): Promise<JsonFileProbe> {
const stat = await statPath(filePath);
if (!stat) {
return {
diagnostics: {
label,
path: displayPath,
resolvedPath: filePath,
exists: false,
isSymlink: false,
isRegularFile: false,
sizeBytes: null,
mtimeMs: null,
parseError: null,
readError: null,
},
json: null,
rawText: '{}',
};
}
const diagnostics: JsonFileDiagnostics = {
label,
path: displayPath,
resolvedPath: filePath,
exists: true,
isSymlink: stat.isSymbolicLink(),
isRegularFile: stat.isFile(),
sizeBytes: stat.size,
mtimeMs: stat.mtimeMs,
parseError: null,
readError: null,
};
if (diagnostics.isSymlink) {
diagnostics.readError = 'Refusing symlink file for safety.';
return { diagnostics, json: null, rawText: '{}' };
}
if (!diagnostics.isRegularFile) {
diagnostics.readError = 'Target is not a regular file.';
return { diagnostics, json: null, rawText: '{}' };
}
try {
const rawText = await fs.readFile(filePath, 'utf8');
try {
const parsed = JSON.parse(rawText);
if (!isObject(parsed)) {
diagnostics.parseError = 'JSON root must be an object.';
return { diagnostics, json: null, rawText };
}
return { diagnostics, json: parsed, rawText };
} catch (error) {
diagnostics.parseError = (error as Error).message;
return { diagnostics, json: null, rawText };
}
} catch (error) {
diagnostics.readError = (error as Error).message;
return { diagnostics, json: null, rawText: '{}' };
}
}
export function parseJsonObjectText(
rawText: string,
fieldName = 'rawText'
): Record<string, unknown> {
let parsed: unknown;
try {
parsed = JSON.parse(rawText);
} catch (error) {
throw new JsonFileValidationError(`Invalid JSON in ${fieldName}: ${(error as Error).message}`);
}
if (!isObject(parsed)) {
throw new JsonFileValidationError(`${fieldName} JSON root must be an object.`);
}
return parsed;
}
export async function writeJsonObjectFileAtomic(
input: WriteJsonObjectFileInput
): Promise<WriteJsonObjectFileResult> {
const fileLabel = input.fileLabel || path.basename(input.filePath);
const parsed = parseJsonObjectText(input.rawText, fileLabel);
const targetPath = input.filePath;
const targetDir = path.dirname(targetPath);
const tempPath = targetPath + '.tmp';
const dirMode = input.dirMode ?? 0o700;
const fileMode = input.fileMode ?? 0o600;
await fs.mkdir(targetDir, { recursive: true, mode: dirMode });
const targetStat = await statPath(targetPath);
if (targetStat) {
const stat = targetStat;
if (stat.isSymbolicLink()) {
throw new Error(`Refusing to write: ${fileLabel} is a symlink.`);
}
if (!stat.isFile()) {
throw new Error(`Refusing to write: ${fileLabel} is not a regular file.`);
}
if (typeof input.expectedMtime !== 'number' || !Number.isFinite(input.expectedMtime)) {
throw new JsonFileConflictError('File metadata not loaded. Refresh and retry.', stat.mtimeMs);
}
if (Math.abs(stat.mtimeMs - input.expectedMtime) > 1000) {
throw new JsonFileConflictError('File modified externally.', stat.mtimeMs);
}
}
let wroteTemp = false;
try {
const existingTempStat = await statPath(tempPath);
if (existingTempStat) {
const tempStat = existingTempStat;
if (tempStat.isSymbolicLink()) {
throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`);
}
if (!tempStat.isFile()) {
throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`);
}
}
await fs.writeFile(tempPath, JSON.stringify(parsed, null, 2) + '\n', { mode: fileMode });
wroteTemp = true;
const tempStat = await fs.lstat(tempPath);
if (tempStat.isSymbolicLink()) {
throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`);
}
if (!tempStat.isFile()) {
throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`);
}
await fs.rename(tempPath, targetPath);
wroteTemp = false;
try {
await fs.chmod(targetPath, fileMode);
} catch {
// Best-effort permission hardening.
}
const stat = await fs.stat(targetPath);
return { mtime: stat.mtimeMs };
} finally {
if (wroteTemp) {
try {
await fs.unlink(tempPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
}
}
}
@@ -0,0 +1,90 @@
export type DroidBinarySource = 'CCS_DROID_PATH' | 'PATH' | 'missing';
export interface DroidBinaryDiagnostics {
installed: boolean;
path: string | null;
installDir: string | null;
source: DroidBinarySource;
version: string | null;
overridePath: string | null;
}
export interface DroidConfigFileDiagnostics {
label: string;
path: string;
resolvedPath: string;
exists: boolean;
isSymlink: boolean;
isRegularFile: boolean;
sizeBytes: number | null;
mtimeMs: number | null;
parseError: string | null;
readError: string | null;
}
export interface DroidCustomModelDiagnostics {
displayName: string;
model: string;
provider: string;
baseUrl: string;
host: string | null;
maxOutputTokens: number | null;
isCcsManaged: boolean;
apiKeyState: 'set' | 'missing';
apiKeyPreview: string | null;
}
export interface DroidByokDiagnostics {
activeModelSelector: string | null;
customModelCount: number;
ccsManagedCount: number;
userManagedCount: number;
invalidModelEntryCount: number;
providerBreakdown: Record<string, number>;
customModels: DroidCustomModelDiagnostics[];
}
export interface CompatibleCliDocLink {
id: string;
label: string;
url: string;
category: 'overview' | 'configuration' | 'byok' | 'reference';
source: 'factory' | 'provider';
description: string;
}
export interface CompatibleCliProviderDocLink {
provider: string;
label: string;
apiFormat: string;
url: string;
}
export interface CompatibleCliDocsReference {
providerValues: string[];
settingsHierarchy: string[];
notes: string[];
links: CompatibleCliDocLink[];
providerDocs: CompatibleCliProviderDocLink[];
}
export interface DroidDashboardDiagnostics {
binary: DroidBinaryDiagnostics;
files: {
settings: DroidConfigFileDiagnostics;
legacyConfig: DroidConfigFileDiagnostics;
};
byok: DroidByokDiagnostics;
warnings: string[];
docsReference: CompatibleCliDocsReference;
}
export interface DroidRawSettingsResponse {
path: string;
resolvedPath: string;
exists: boolean;
mtime: number;
rawText: string;
settings: Record<string, unknown> | null;
parseError: string | null;
}
@@ -0,0 +1,283 @@
import * as os from 'os';
import * as path from 'path';
import { execFileSync } from 'child_process';
import { detectDroidCli } from '../../targets/droid-detector';
import type {
DroidByokDiagnostics,
DroidCustomModelDiagnostics,
DroidDashboardDiagnostics,
DroidRawSettingsResponse,
} from './compatible-cli-types';
import {
JsonFileConflictError,
JsonFileValidationError,
probeJsonObjectFile,
writeJsonObjectFileAtomic,
} from './compatible-cli-json-file-service';
import { getCompatibleCliDocsReference } from './compatible-cli-docs-registry';
interface DroidConfigPaths {
settingsPath: string;
settingsDisplayPath: string;
legacyConfigPath: string;
legacyConfigDisplayPath: string;
}
interface SaveDroidRawSettingsInput {
rawText: string;
expectedMtime?: number;
}
interface SaveDroidRawSettingsResult {
success: true;
mtime: number;
}
export {
JsonFileConflictError as DroidRawSettingsConflictError,
JsonFileValidationError as DroidRawSettingsValidationError,
};
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function asObject(value: unknown): Record<string, unknown> | null {
return isObject(value) ? value : null;
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function asNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function parseHost(value: string): string | null {
try {
return new URL(value).host || null;
} catch {
return null;
}
}
export function maskApiKeyPreview(value: string): string {
if (!value) return '';
const suffix = value.slice(-4);
return `***${suffix}`;
}
function isCcsManagedDisplayName(displayName: string): boolean {
return displayName.startsWith('CCS ') || displayName.startsWith('ccs-');
}
export function resolveDroidConfigPaths(
options: {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
homeDir?: string;
} = {}
): DroidConfigPaths {
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? os.homedir();
const byokBase = env.CCS_HOME || homeDir;
const settingsPath = path.join(byokBase, '.factory', 'settings.json');
const legacyConfigPath = path.join(byokBase, '.factory', 'config.json');
return {
settingsPath,
settingsDisplayPath: '~/.factory/settings.json',
legacyConfigPath,
legacyConfigDisplayPath: '~/.factory/config.json',
};
}
function getBinaryVersion(binaryPath: string): string | null {
try {
return execFileSync(binaryPath, ['--version'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
})
.trim()
.split('\n')[0]
.trim();
} catch {
return null;
}
}
export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByokDiagnostics {
const rows: DroidCustomModelDiagnostics[] = [];
const providerBreakdown: Record<string, number> = {};
let invalidModelEntryCount = 0;
const source = Array.isArray(customModelsValue)
? customModelsValue
: isObject(customModelsValue)
? Object.values(customModelsValue)
: [];
for (const item of source) {
if (!isObject(item)) {
invalidModelEntryCount += 1;
continue;
}
const displayName = asString(item.displayName) ?? asString(item.model_display_name);
const model = asString(item.model);
const baseUrl = asString(item.baseUrl) ?? asString(item.base_url);
const providerRaw = asString(item.provider);
const apiKey = asString(item.apiKey) ?? asString(item.api_key);
if (!displayName || !model || !baseUrl || !providerRaw) {
invalidModelEntryCount += 1;
continue;
}
const provider = providerRaw.toLowerCase();
providerBreakdown[provider] = (providerBreakdown[provider] ?? 0) + 1;
rows.push({
displayName,
model,
provider,
baseUrl,
host: parseHost(baseUrl),
maxOutputTokens: asNumber(item.maxOutputTokens) ?? asNumber(item.max_tokens),
isCcsManaged: isCcsManagedDisplayName(displayName),
apiKeyState: apiKey ? 'set' : 'missing',
apiKeyPreview: apiKey ? maskApiKeyPreview(apiKey) : null,
});
}
const ccsManagedCount = rows.filter((row) => row.isCcsManaged).length;
return {
activeModelSelector: null,
customModelCount: rows.length,
ccsManagedCount,
userManagedCount: rows.length - ccsManagedCount,
invalidModelEntryCount,
providerBreakdown,
customModels: rows,
};
}
function resolveCustomModelsValue(settings: Record<string, unknown> | null): unknown {
if (!settings) return undefined;
const modern = settings.customModels;
if (Array.isArray(modern) || isObject(modern)) return modern;
const legacy = settings.custom_models;
if (Array.isArray(legacy) || isObject(legacy)) return legacy;
return undefined;
}
function usesLegacyCustomModelsKey(settings: Record<string, unknown> | null): boolean {
if (!settings) return false;
const modern = settings.customModels;
if (Array.isArray(modern) || isObject(modern)) return false;
const legacy = settings.custom_models;
return Array.isArray(legacy) || isObject(legacy);
}
export async function getDroidDashboardDiagnostics(): Promise<DroidDashboardDiagnostics> {
const paths = resolveDroidConfigPaths();
const binaryPath = detectDroidCli();
const docsReference = getCompatibleCliDocsReference('droid');
const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing';
const settingsProbe = await probeJsonObjectFile(
paths.settingsPath,
'BYOK settings',
paths.settingsDisplayPath
);
const legacyConfigProbe = await probeJsonObjectFile(
paths.legacyConfigPath,
'Legacy config',
paths.legacyConfigDisplayPath
);
const settingsJson = asObject(settingsProbe.json);
const legacyJson = asObject(legacyConfigProbe.json);
const settingsCustomModels = resolveCustomModelsValue(settingsJson);
const legacyCustomModels = resolveCustomModelsValue(legacyJson);
const byok = summarizeDroidCustomModels(settingsCustomModels ?? legacyCustomModels);
byok.activeModelSelector = asString(settingsProbe.json?.model);
const warnings: string[] = [];
if (!binaryPath) warnings.push('Droid binary is not detected in PATH or CCS_DROID_PATH.');
if (settingsProbe.diagnostics.parseError) {
warnings.push('~/.factory/settings.json contains invalid JSON.');
}
if (byok.invalidModelEntryCount > 0) {
warnings.push(`${byok.invalidModelEntryCount} customModels entries are malformed.`);
}
if (legacyConfigProbe.diagnostics.parseError) {
warnings.push('Legacy Droid config (~/.factory/config.json) JSON is invalid.');
}
if (usesLegacyCustomModelsKey(settingsJson)) {
warnings.push(
'settings.json uses legacy "custom_models" key; prefer "customModels" for forward compatibility.'
);
}
return {
binary: {
installed: !!binaryPath,
path: binaryPath,
installDir: binaryPath ? path.dirname(binaryPath) : null,
source,
version: binaryPath ? getBinaryVersion(binaryPath) : null,
overridePath: process.env.CCS_DROID_PATH || null,
},
files: {
settings: settingsProbe.diagnostics,
legacyConfig: legacyConfigProbe.diagnostics,
},
byok,
warnings,
docsReference,
};
}
export async function getDroidRawSettings(): Promise<DroidRawSettingsResponse> {
const paths = resolveDroidConfigPaths();
const settingsProbe = await probeJsonObjectFile(
paths.settingsPath,
'BYOK settings',
paths.settingsDisplayPath
);
return {
path: paths.settingsDisplayPath,
resolvedPath: paths.settingsPath,
exists: settingsProbe.diagnostics.exists,
mtime: settingsProbe.diagnostics.mtimeMs ?? Date.now(),
rawText: settingsProbe.rawText,
settings: settingsProbe.json,
parseError: settingsProbe.diagnostics.parseError,
};
}
export async function saveDroidRawSettings(
input: SaveDroidRawSettingsInput
): Promise<SaveDroidRawSettingsResult> {
const paths = resolveDroidConfigPaths();
if (typeof input.rawText !== 'string') {
throw new JsonFileValidationError('rawText must be a string.');
}
const saved = await writeJsonObjectFileAtomic({
filePath: paths.settingsPath,
rawText: input.rawText,
expectedMtime: input.expectedMtime,
fileLabel: 'settings.json',
});
return { success: true, mtime: saved.mtime };
}
+458 -61
View File
@@ -7,11 +7,20 @@
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';
import { getCcsDir } from '../utils/config-manager';
import { getClaudeConfigDir } from '../utils/claude-config-path';
export const sharedRoutes = Router();
const MAX_DIRECTORY_TRAVERSAL_DEPTH = 10;
const MAX_DESCRIPTION_LENGTH = 140;
const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB
const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB
const SHARED_ITEMS_CACHE_TTL_MS = 1000;
type SharedCollectionType = 'commands' | 'skills' | 'agents';
interface SharedItem {
name: string;
description: string;
@@ -19,6 +28,14 @@ interface SharedItem {
type: 'command' | 'skill' | 'agent';
}
interface SharedItemsCacheEntry {
items: SharedItem[];
sharedDir: string;
expiresAt: number;
}
const sharedItemsCache = new Map<SharedCollectionType, SharedItemsCacheEntry>();
/**
* GET /api/shared/commands
*/
@@ -43,6 +60,41 @@ sharedRoutes.get('/agents', (_req: Request, res: Response) => {
res.json({ items });
});
/**
* GET /api/shared/content?type=commands|skills|agents&path=<item-path>
*/
sharedRoutes.get('/content', (req: Request, res: Response) => {
const typeParam = req.query.type;
const itemPathParam = req.query.path;
if (!isSharedCollectionType(typeParam)) {
res.status(400).json({ error: 'Invalid or missing type parameter' });
return;
}
if (typeof itemPathParam !== 'string' || itemPathParam.trim().length === 0) {
res.status(400).json({ error: 'Invalid or missing path parameter' });
return;
}
const ccsDir = getCcsDir();
const sharedDir = path.join(ccsDir, 'shared', typeParam);
if (!fs.existsSync(sharedDir)) {
res.status(404).json({ error: 'Shared directory not found' });
return;
}
const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
const allowedRoots = resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot);
const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots);
if (!contentResult) {
res.status(404).json({ error: 'Shared content not found' });
return;
}
res.json(contentResult);
});
/**
* GET /api/shared/summary
*/
@@ -60,17 +112,20 @@ sharedRoutes.get('/summary', (_req: Request, res: Response) => {
});
});
function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] {
const ccsDir = getCcsDir();
const sharedDir = path.join(ccsDir, 'shared', type);
function isSharedCollectionType(value: unknown): value is SharedCollectionType {
return value === 'commands' || value === 'skills' || value === 'agents';
}
if (!fs.existsSync(sharedDir)) {
return [];
function resolveAllowedRoots(
type: SharedCollectionType,
ccsDir: string,
sharedDirRoot: string
): Set<string> {
if (type === 'commands') {
return new Set<string>([sharedDirRoot]);
}
const items: SharedItem[] = [];
const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
const allowedSkillAgentRoots = new Set<string>([
return new Set<string>([
sharedDirRoot,
...[
path.join(getClaudeConfigDir(), type),
@@ -80,6 +135,36 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] {
.map((dirPath) => safeRealPath(dirPath))
.filter((dirPath): dirPath is string => typeof dirPath === 'string'),
]);
}
function getSharedItems(type: SharedCollectionType): SharedItem[] {
const ccsDir = getCcsDir();
const sharedDir = path.join(ccsDir, 'shared', type);
const now = Date.now();
if (!fs.existsSync(sharedDir)) {
sharedItemsCache.delete(type);
return [];
}
const cached = sharedItemsCache.get(type);
if (cached && cached.sharedDir === sharedDir && cached.expiresAt > now) {
return cached.items;
}
const items: SharedItem[] = [];
const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
const allowedRoots = resolveAllowedRoots(type, ccsDir, sharedDirRoot);
if (type === 'commands') {
const commandItems = getCommandItems(sharedDir, allowedRoots);
sharedItemsCache.set(type, {
items: commandItems,
sharedDir,
expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS,
});
return commandItems;
}
try {
const entries = fs.readdirSync(sharedDir, { withFileTypes: true });
@@ -87,56 +172,25 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] {
for (const entry of entries) {
try {
const entryPath = path.join(sharedDir, entry.name);
if (type === 'commands') {
if (!entry.name.endsWith('.md')) {
continue;
}
if (!entry.isFile() && !entry.isSymbolicLink()) {
continue;
}
const commandPath = safeRealPath(entryPath);
if (!commandPath || !isPathWithin(commandPath, sharedDirRoot)) {
continue;
}
const description = readMarkdownDescription(commandPath, sharedDirRoot);
if (!description) {
continue;
}
items.push({
name: entry.name.replace('.md', ''),
description,
path: entryPath,
type: 'command',
});
const resolvedEntryPath = safeRealPath(entryPath);
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
continue;
}
// Skills/agents are directory-based and may be symlinked directories.
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
const stats = fs.statSync(resolvedEntryPath);
const item = getSkillOrAgentItem(
type,
entry,
entryPath,
resolvedEntryPath,
allowedRoots,
stats
);
if (!item) {
continue;
}
const entryRoot = safeRealPath(entryPath);
if (!entryRoot || !isPathWithinAny(entryRoot, allowedSkillAgentRoots)) {
continue;
}
const markdownFile = type === 'skills' ? 'SKILL.md' : 'prompt.md';
const description = readMarkdownDescription(path.join(entryRoot, markdownFile), entryRoot);
if (!description) {
continue;
}
items.push({
name: entry.name,
description,
path: entryPath,
type: type === 'skills' ? 'skill' : 'agent',
});
items.push(item);
} catch {
// Fail soft per entry so one bad item does not hide valid results.
}
@@ -145,25 +199,259 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] {
// Directory read failed
}
const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name));
sharedItemsCache.set(type, {
items: sortedItems,
sharedDir,
expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS,
});
return sortedItems;
}
function getCommandItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots);
const items: SharedItem[] = [];
for (const markdownFile of markdownFiles) {
const description = readMarkdownDescription(markdownFile.resolvedPath, allowedRoots);
if (!description) {
continue;
}
const relativePath = path.relative(sharedDir, markdownFile.displayPath);
const normalizedName = relativePath.split(path.sep).join('/').replace(/\.md$/i, '');
if (!normalizedName) {
continue;
}
items.push({
name: normalizedName,
description,
path: markdownFile.displayPath,
type: 'command',
});
}
return items.sort((a, b) => a.name.localeCompare(b.name));
}
function extractDescription(content: string): string {
// Extract first non-empty, non-heading line
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('---')) {
return trimmed.slice(0, 100);
function getSkillOrAgentItem(
type: 'skills' | 'agents',
entry: fs.Dirent,
entryPath: string,
resolvedEntryPath: string,
allowedRoots: Set<string>,
stats: fs.Stats
): SharedItem | null {
if (type === 'skills') {
if (!stats.isDirectory()) {
return null;
}
const description = readMarkdownDescription(
path.join(resolvedEntryPath, 'SKILL.md'),
allowedRoots
);
if (!description) {
return null;
}
return {
name: entry.name,
description,
path: entryPath,
type: 'skill',
};
}
if (stats.isDirectory()) {
const description = readFirstMarkdownDescription(
[
path.join(resolvedEntryPath, 'prompt.md'),
path.join(resolvedEntryPath, 'AGENT.md'),
path.join(resolvedEntryPath, 'agent.md'),
],
allowedRoots
);
if (!description) {
return null;
}
return {
name: entry.name,
description,
path: entryPath,
type: 'agent',
};
}
if (!stats.isFile() || !entry.name.toLowerCase().endsWith('.md')) {
return null;
}
const description = readMarkdownDescription(resolvedEntryPath, allowedRoots);
if (!description) {
return null;
}
return {
name: entry.name.replace(/\.md$/i, ''),
description,
path: entryPath,
type: 'agent',
};
}
interface MarkdownFileEntry {
displayPath: string;
resolvedPath: string;
}
function collectMarkdownFiles(sharedDir: string, allowedRoots: Set<string>): MarkdownFileEntry[] {
const directoriesToVisit: Array<{ path: string; depth: number }> = [
{ path: sharedDir, depth: 0 },
];
const visitedDirectories = new Set<string>();
const markdownFiles: MarkdownFileEntry[] = [];
while (directoriesToVisit.length > 0) {
const current = directoriesToVisit.pop();
if (!current) {
continue;
}
const currentDir = current.path;
const resolvedCurrentDir = safeRealPath(currentDir);
if (!resolvedCurrentDir || !isPathWithinAny(resolvedCurrentDir, allowedRoots)) {
continue;
}
const normalizedDirPath = normalizeForPathComparison(resolvedCurrentDir);
if (visitedDirectories.has(normalizedDirPath)) {
continue;
}
visitedDirectories.add(normalizedDirPath);
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(currentDir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const entryPath = path.join(currentDir, entry.name);
const resolvedEntryPath = safeRealPath(entryPath);
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
continue;
}
let stats: fs.Stats;
try {
stats = fs.statSync(resolvedEntryPath);
} catch {
continue;
}
if (stats.isDirectory()) {
if (current.depth < MAX_DIRECTORY_TRAVERSAL_DEPTH) {
directoriesToVisit.push({ path: entryPath, depth: current.depth + 1 });
}
continue;
}
if (stats.isFile() && entry.name.toLowerCase().endsWith('.md')) {
markdownFiles.push({
displayPath: entryPath,
resolvedPath: resolvedEntryPath,
});
}
}
}
return markdownFiles;
}
function extractDescription(content: string): string {
const frontmatterDescription = extractFrontmatterDescription(content);
if (frontmatterDescription) {
return trimDescription(frontmatterDescription);
}
// Extract first non-empty, non-heading line from the markdown body.
const lines = stripFrontmatter(content).split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (isDescriptionBodyLine(trimmed)) {
return trimDescription(trimmed);
}
}
return 'No description';
}
function readMarkdownDescription(markdownPath: string, allowedRoot: string): string | null {
function isDescriptionBodyLine(line: string): boolean {
if (!line) {
return false;
}
if (line === '---' || line === '...') {
return false;
}
return !line.startsWith('#') && !line.startsWith('<!--');
}
function extractFrontmatterDescription(content: string): string | null {
const frontmatterMatch = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\s*\r?\n|$)/);
if (!frontmatterMatch) {
return null;
}
try {
const parsed = yaml.load(frontmatterMatch[1]) as Record<string, unknown> | null;
const description = parsed?.description;
if (typeof description !== 'string') {
return null;
}
const trimmed = description.trim();
return trimmed.length > 0 ? trimmed : null;
} catch {
return null;
}
}
function stripFrontmatter(content: string): string {
return content.replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n?/, '');
}
function trimDescription(description: string): string {
if (description.length <= MAX_DESCRIPTION_LENGTH) {
return description;
}
return `${description.slice(0, MAX_DESCRIPTION_LENGTH - 3).trimEnd()}...`;
}
function readFirstMarkdownDescription(
markdownPaths: string[],
allowedRoots: Set<string>
): string | null {
for (const markdownPath of markdownPaths) {
const description = readMarkdownDescription(markdownPath, allowedRoots);
if (description) {
return description;
}
}
return null;
}
function readMarkdownDescription(markdownPath: string, allowedRoots: Set<string>): string | null {
try {
const resolvedMarkdownPath = safeRealPath(markdownPath);
if (!resolvedMarkdownPath || !isPathWithin(resolvedMarkdownPath, allowedRoot)) {
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
return null;
}
@@ -171,6 +459,9 @@ function readMarkdownDescription(markdownPath: string, allowedRoot: string): str
if (!stats.isFile()) {
return null;
}
if (stats.size > MAX_MARKDOWN_FILE_BYTES) {
return null;
}
const content = fs.readFileSync(resolvedMarkdownPath, 'utf8');
return extractDescription(content);
} catch {
@@ -178,6 +469,112 @@ function readMarkdownDescription(markdownPath: string, allowedRoot: string): str
}
}
function readMarkdownContent(markdownPath: string, allowedRoots: Set<string>): string | null {
try {
const resolvedMarkdownPath = safeRealPath(markdownPath);
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
return null;
}
const stats = fs.statSync(resolvedMarkdownPath);
if (!stats.isFile()) {
return null;
}
if (stats.size > MAX_CONTENT_FILE_BYTES) {
return null;
}
return fs.readFileSync(resolvedMarkdownPath, 'utf8');
} catch {
return null;
}
}
function resolveReadableMarkdownPath(
markdownPaths: string[],
allowedRoots: Set<string>
): string | null {
for (const markdownPath of markdownPaths) {
const resolvedMarkdownPath = safeRealPath(markdownPath);
if (!resolvedMarkdownPath || !isPathWithinAny(resolvedMarkdownPath, allowedRoots)) {
continue;
}
try {
const stats = fs.statSync(resolvedMarkdownPath);
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
continue;
}
return resolvedMarkdownPath;
} catch {
continue;
}
}
return null;
}
function getSharedItemContent(
type: SharedCollectionType,
itemPath: string,
allowedRoots: Set<string>
): { content: string; contentPath: string } | null {
const resolvedItemPath = safeRealPath(itemPath);
if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) {
return null;
}
let itemStats: fs.Stats;
try {
itemStats = fs.statSync(resolvedItemPath);
} catch {
return null;
}
let markdownPath: string | null = null;
if (type === 'commands') {
if (!itemStats.isFile() || !itemPath.toLowerCase().endsWith('.md')) {
return null;
}
markdownPath = resolvedItemPath;
} else if (type === 'skills') {
if (!itemStats.isDirectory()) {
return null;
}
markdownPath = resolveReadableMarkdownPath(
[path.join(resolvedItemPath, 'SKILL.md')],
allowedRoots
);
} else {
if (itemStats.isDirectory()) {
markdownPath = resolveReadableMarkdownPath(
[
path.join(resolvedItemPath, 'prompt.md'),
path.join(resolvedItemPath, 'AGENT.md'),
path.join(resolvedItemPath, 'agent.md'),
],
allowedRoots
);
} else if (itemStats.isFile() && itemPath.toLowerCase().endsWith('.md')) {
markdownPath = resolvedItemPath;
}
}
if (!markdownPath) {
return null;
}
const content = readMarkdownContent(markdownPath, allowedRoots);
if (!content) {
return null;
}
return {
content,
contentPath: markdownPath,
};
}
function safeRealPath(targetPath: string): string | null {
try {
return fs.realpathSync(targetPath);
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'bun:test';
import {
MAX_CONTEXT_GROUP_LENGTH,
isValidAccountProfileName,
policyToAccountContextMetadata,
resolveAccountContextPolicy,
resolveCreateAccountContext,
} from '../../src/auth/account-context';
describe('account context helpers', () => {
it('rejects context groups that exceed the max length', () => {
const group = `a${'x'.repeat(MAX_CONTEXT_GROUP_LENGTH)}`;
const result = resolveCreateAccountContext({ shareContext: false, contextGroup: group });
expect(result.error).toContain('Invalid context group');
});
it('rejects profile names with unsupported characters', () => {
expect(isValidAccountProfileName('work')).toBe(true);
expect(isValidAccountProfileName('gemini:default')).toBe(false);
});
it('falls back to default shared group for invalid persisted metadata', () => {
const resolved = resolveAccountContextPolicy({
context_mode: 'shared',
context_group: '###',
});
expect(resolved.mode).toBe('shared');
expect(resolved.group).toBe('default');
});
it('round-trips shared policy metadata with normalized context group', () => {
const metadata = policyToAccountContextMetadata({
mode: 'shared',
group: 'Sprint-A',
});
const resolved = resolveAccountContextPolicy(metadata);
expect(resolved.mode).toBe('shared');
expect(resolved.group).toBe('sprint-a');
});
it('normalizes whitespace in explicit shared context group names', () => {
const result = resolveCreateAccountContext({
shareContext: false,
contextGroup: ' Team Alpha ',
});
expect(result.error).toBeUndefined();
expect(result.policy.mode).toBe('shared');
expect(result.policy.group).toBe('team-alpha');
});
it('supports deeper continuity for shared create flows', () => {
const result = resolveCreateAccountContext({
shareContext: true,
deeperContinuity: true,
});
expect(result.error).toBeUndefined();
expect(result.policy.mode).toBe('shared');
expect(result.policy.continuityMode).toBe('deeper');
});
it('rejects deeper continuity without shared context flags', () => {
const result = resolveCreateAccountContext({
shareContext: false,
deeperContinuity: true,
});
expect(result.error).toContain('requires shared context');
});
it('defaults shared continuity mode to standard for legacy metadata', () => {
const resolved = resolveAccountContextPolicy({
context_mode: 'shared',
context_group: 'team-alpha',
});
expect(resolved.mode).toBe('shared');
expect(resolved.group).toBe('team-alpha');
expect(resolved.continuityMode).toBe('standard');
});
});
+22
View File
@@ -31,4 +31,26 @@ describe('auth command args parsing', () => {
expect(parsed.profileName).toBe('work');
expect(parsed.contextGroup).toBe('');
});
it('flags empty inline context group as empty string', () => {
const parsed = parseArgs(['work', '--context-group=']);
expect(parsed.profileName).toBe('work');
expect(parsed.contextGroup).toBe('');
});
it('parses deeper continuity flag for create command', () => {
const parsed = parseArgs(['work', '--share-context', '--deeper-continuity']);
expect(parsed.profileName).toBe('work');
expect(parsed.shareContext).toBe(true);
expect(parsed.deeperContinuity).toBe(true);
});
it('tracks unknown flags and keeps positional profile intact', () => {
const parsed = parseArgs(['--foo', 'bar', 'work']);
expect(parsed.profileName).toBe('work');
expect(parsed.unknownFlags).toEqual(['--foo']);
});
});
+174
View File
@@ -0,0 +1,174 @@
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 ProfileRegistry from '../../src/auth/profile-registry';
import InstanceManager from '../../src/management/instance-manager';
import { handleList } from '../../src/auth/commands/list-command';
describe('auth list context metadata', () => {
let tempRoot = '';
let originalCcsHome: string | undefined;
let originalCcsUnified: string | undefined;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auth-list-context-'));
originalCcsHome = process.env.CCS_HOME;
originalCcsUnified = process.env.CCS_UNIFIED_CONFIG;
process.env.CCS_HOME = tempRoot;
process.env.CCS_UNIFIED_CONFIG = '1';
});
afterEach(() => {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalCcsUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalCcsUnified;
else delete process.env.CCS_UNIFIED_CONFIG;
if (tempRoot && fs.existsSync(tempRoot)) {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it('keeps unified account context metadata in JSON list output', async () => {
const ccsDir = path.join(tempRoot, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 8',
'accounts:',
' work:',
' created: "2026-02-01T00:00:00.000Z"',
' last_used: null',
' context_mode: shared',
' context_group: sprint-a',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const registry = new ProfileRegistry();
const instanceMgr = new InstanceManager();
const lines: string[] = [];
const originalLog = console.log;
console.log = (...args: unknown[]) => {
lines.push(args.map(String).join(' '));
};
try {
await handleList(
{
registry,
instanceMgr,
version: 'test',
},
['--json']
);
} finally {
console.log = originalLog;
}
const payload = JSON.parse(lines.join('\n')) as {
profiles: Array<{
name: string;
context_mode?: string;
context_group?: string | null;
continuity_mode?: string | null;
}>;
};
const work = payload.profiles.find((profile) => profile.name === 'work');
expect(work).toBeTruthy();
expect(work?.context_mode).toBe('shared');
expect(work?.context_group).toBe('sprint-a');
expect(work?.continuity_mode).toBe('standard');
});
it('prefers unified context metadata over legacy when profile names overlap', async () => {
const ccsDir = path.join(tempRoot, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'profiles.json'),
JSON.stringify(
{
version: '2.0.0',
profiles: {
work: {
type: 'account',
created: '2026-01-01T00:00:00.000Z',
last_used: null,
context_mode: 'isolated',
},
},
default: null,
},
null,
2
)
);
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 8',
'accounts:',
' work:',
' created: "2026-02-01T00:00:00.000Z"',
' last_used: null',
' context_mode: shared',
' context_group: sprint-a',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const registry = new ProfileRegistry();
const instanceMgr = new InstanceManager();
const lines: string[] = [];
const originalLog = console.log;
console.log = (...args: unknown[]) => {
lines.push(args.map(String).join(' '));
};
try {
await handleList(
{
registry,
instanceMgr,
version: 'test',
},
['--json']
);
} finally {
console.log = originalLog;
}
const payload = JSON.parse(lines.join('\n')) as {
profiles: Array<{
name: string;
context_mode?: string;
context_group?: string | null;
continuity_mode?: string | null;
}>;
};
const work = payload.profiles.find((profile) => profile.name === 'work');
expect(work).toBeTruthy();
expect(work?.context_mode).toBe('shared');
expect(work?.context_group).toBe('sprint-a');
expect(work?.continuity_mode).toBe('standard');
});
});
@@ -0,0 +1,94 @@
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 ProfileRegistry from '../../../src/auth/profile-registry';
describe('profile-registry context normalization', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
let originalUnifiedMode: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-registry-context-'));
originalCcsHome = process.env.CCS_HOME;
originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG;
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalUnifiedMode !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode;
else delete process.env.CCS_UNIFIED_CONFIG;
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('drops non-string legacy context_group values without throwing', () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'profiles.json'),
JSON.stringify(
{
version: '2.0.0',
default: null,
profiles: {
work: {
type: 'account',
created: '2026-02-24T00:00:00.000Z',
last_used: null,
context_mode: 'shared',
context_group: { invalid: true },
},
},
},
null,
2
),
'utf8'
);
const registry = new ProfileRegistry();
const profile = registry.getProfile('work');
expect(profile.context_mode).toBe('shared');
expect(profile.context_group).toBeUndefined();
expect(profile.continuity_mode).toBe('standard');
});
it('drops non-string unified context_group values without throwing', () => {
process.env.CCS_UNIFIED_CONFIG = '1';
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 8',
'accounts:',
' work:',
' created: "2026-02-24T00:00:00.000Z"',
' last_used: null',
' context_mode: shared',
' context_group: 123',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const registry = new ProfileRegistry();
const accounts = registry.getAllAccountsUnified();
expect(accounts.work.context_mode).toBe('shared');
expect(accounts.work.context_group).toBeUndefined();
expect(accounts.work.continuity_mode).toBe('standard');
});
});
@@ -0,0 +1,130 @@
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 {
ANTIGRAVITY_ACK_PHRASE,
ANTIGRAVITY_ACK_VERSION,
hasAntigravityRiskAcceptanceFlag,
isAntigravityResponsibilityBypassEnabled,
validateAntigravityRiskAcknowledgement,
} from '../../../src/cliproxy/antigravity-responsibility';
describe('antigravity-responsibility', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
let originalAgyRiskEnv: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-risk-test-'));
originalCcsHome = process.env.CCS_HOME;
originalAgyRiskEnv = process.env.CCS_ACCEPT_AGY_RISK;
process.env.CCS_HOME = tempHome;
delete process.env.CCS_ACCEPT_AGY_RISK;
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (originalAgyRiskEnv !== undefined) {
process.env.CCS_ACCEPT_AGY_RISK = originalAgyRiskEnv;
} else {
delete process.env.CCS_ACCEPT_AGY_RISK;
}
fs.rmSync(tempHome, { recursive: true, force: true });
});
it('accepts a complete acknowledgement payload', () => {
const result = validateAntigravityRiskAcknowledgement({
version: ANTIGRAVITY_ACK_VERSION,
reviewedIssue509: true,
understandsBanRisk: true,
acceptsFullResponsibility: true,
typedPhrase: ANTIGRAVITY_ACK_PHRASE,
});
expect(result.valid).toBeTrue();
});
it('accepts phrase with extra spacing and lowercase', () => {
const result = validateAntigravityRiskAcknowledgement({
version: ANTIGRAVITY_ACK_VERSION,
reviewedIssue509: true,
understandsBanRisk: true,
acceptsFullResponsibility: true,
typedPhrase: ' i accept risk ',
});
expect(result.valid).toBeTrue();
});
it('rejects payload when checklist steps are not fully completed', () => {
const result = validateAntigravityRiskAcknowledgement({
version: ANTIGRAVITY_ACK_VERSION,
reviewedIssue509: true,
understandsBanRisk: false,
acceptsFullResponsibility: true,
typedPhrase: ANTIGRAVITY_ACK_PHRASE,
});
expect(result.valid).toBeFalse();
expect(result.error).toContain('checklist');
});
it('rejects payload when version is outdated', () => {
const result = validateAntigravityRiskAcknowledgement({
version: 'older-version',
reviewedIssue509: true,
understandsBanRisk: true,
acceptsFullResponsibility: true,
typedPhrase: ANTIGRAVITY_ACK_PHRASE,
});
expect(result.valid).toBeFalse();
expect(result.error).toContain('version');
});
it('rejects payload when phrase does not match', () => {
const result = validateAntigravityRiskAcknowledgement({
version: ANTIGRAVITY_ACK_VERSION,
reviewedIssue509: true,
understandsBanRisk: true,
acceptsFullResponsibility: true,
typedPhrase: 'I AGREE',
});
expect(result.valid).toBeFalse();
expect(result.error).toContain('phrase');
});
it('detects explicit antigravity acceptance flags', () => {
expect(hasAntigravityRiskAcceptanceFlag(['--accept-agr-risk'])).toBeTrue();
expect(hasAntigravityRiskAcceptanceFlag(['--accept-antigravity-risk'])).toBeTrue();
expect(hasAntigravityRiskAcceptanceFlag(['--auth'])).toBeFalse();
});
it('enables bypass when CCS_ACCEPT_AGY_RISK is set', () => {
process.env.CCS_ACCEPT_AGY_RISK = 'true';
expect(isAntigravityResponsibilityBypassEnabled()).toBeTrue();
});
it('enables bypass when cliproxy safety setting is enabled', () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
`version: 8
cliproxy:
safety:
antigravity_ack_bypass: true
`
);
expect(isAntigravityResponsibilityBypassEnabled()).toBeTrue();
});
});
+35 -1
View File
@@ -587,11 +587,15 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
assert(config.includes('claude-sonnet-4-5'), 'Should include Claude sonnet model');
assert(config.includes('claude-sonnet-4-6'), 'Should include Claude Sonnet 4.6 model');
assert(config.includes('fork: true'), 'Should include fork: true for Claude aliases');
assert(
!config.includes('alias: gemini-claude-'),
'Should not emit deprecated gemini-claude aliases'
);
// Verify fork: true appears after each Claude alias entry
const lines = config.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes('alias: gemini-claude-')) {
if (lines[i].includes('alias: claude-')) {
assert(
lines[i + 1] && lines[i + 1].trim() === 'fork: true',
`fork: true should follow Claude alias at line ${i}: ${lines[i]}`
@@ -766,5 +770,35 @@ oauth-model-alias:
}
}
});
it('normalizes deprecated gemini-claude aliases during regeneration', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
const initialConfig = `# CLIProxyAPI config generated by CCS v9
port: 8317
api-keys:
- "ccs-internal-managed"
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
oauth-model-alias:
antigravity:
- name: claude-sonnet-4-6-thinking
alias: gemini-claude-sonnet-4-6-thinking
fork: true
`;
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
regenerateConfig();
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
assert(
newConfig.includes('alias: claude-sonnet-4-6-thinking'),
'Should include normalized upstream Claude alias'
);
assert(
!newConfig.includes('alias: gemini-claude-sonnet-4-6-thinking'),
'Should remove deprecated gemini-claude alias after regeneration'
);
});
});
});
@@ -146,6 +146,51 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini');
});
it('migrates iflow placeholder model IDs to a supported default', () => {
const iflowSettingsPath = path.join(tempHome, 'iflow.settings.json');
writeSettings(
iflowSettingsPath,
{
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/iflow',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'iflow-default',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'iflow-default',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'iflow-default',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'iflow-default',
},
{
presets: [
{
name: 'legacy-iflow',
default: 'iflow-default',
opus: 'iflow-default',
sonnet: 'iflow-default',
haiku: 'iflow-default',
},
],
}
);
const env = getEffectiveEnvVars('iflow', 8317, iflowSettingsPath);
expect(env.ANTHROPIC_MODEL).toBe('qwen3-coder-plus');
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('qwen3-coder-plus');
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('qwen3-coder-plus');
const persisted = JSON.parse(fs.readFileSync(iflowSettingsPath, 'utf-8')) as {
env: Record<string, string>;
presets: Array<Record<string, string>>;
};
expect(persisted.env.ANTHROPIC_MODEL).toBe('qwen3-coder-plus');
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('qwen3-coder-plus');
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('qwen3-coder-plus');
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('qwen3-coder-plus');
expect(persisted.presets[0]?.default).toBe('qwen3-coder-plus');
expect(persisted.presets[0]?.opus).toBe('qwen3-coder-plus');
expect(persisted.presets[0]?.sonnet).toBe('qwen3-coder-plus');
expect(persisted.presets[0]?.haiku).toBe('qwen3-coder-plus');
});
it('repairs existing provider settings files that are missing env keys', () => {
process.env.CCS_HOME = tempHome;
const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
@@ -4,9 +4,11 @@
*/
import * as assert from 'assert';
const fs = require('fs');
describe('Profile Mapper', () => {
const profileMapper = require('../../../dist/cliproxy/sync/profile-mapper');
const profileReader = require('../../../dist/api/services/profile-reader');
describe('mapProfileToClaudeKey', () => {
it('returns null when env is missing', () => {
@@ -86,6 +88,100 @@ describe('Profile Mapper', () => {
const result = profileMapper.loadSyncableProfiles();
assert.ok(Array.isArray(result));
});
it('uses profile-provided settingsPath instead of reconstructing from profile name', () => {
const originalListApiProfiles = profileReader.listApiProfiles;
const originalExistsSync = fs.existsSync;
const originalReadFileSync = fs.readFileSync;
const customSettingsPath = '/tmp/custom-sync-path.settings.json';
const readPaths: string[] = [];
try {
profileReader.listApiProfiles = () => ({
profiles: [
{
name: 'glm',
settingsPath: customSettingsPath,
isConfigured: true,
configSource: 'legacy',
target: 'claude',
},
],
variants: [],
});
fs.existsSync = (filePath: string) => filePath === customSettingsPath;
fs.readFileSync = (filePath: string) => {
readPaths.push(filePath);
return JSON.stringify({
env: {
ANTHROPIC_AUTH_TOKEN: 'sk-test-key',
},
});
};
const result = profileMapper.loadSyncableProfiles();
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].settingsPath, customSettingsPath);
assert.deepStrictEqual(readPaths, [customSettingsPath]);
} finally {
profileReader.listApiProfiles = originalListApiProfiles;
fs.existsSync = originalExistsSync;
fs.readFileSync = originalReadFileSync;
}
});
it('skips profiles pinned to non-claude targets during local sync mapping', () => {
const originalListApiProfiles = profileReader.listApiProfiles;
const originalExistsSync = fs.existsSync;
const originalReadFileSync = fs.readFileSync;
const claudePath = '/tmp/claude-target.settings.json';
const droidPath = '/tmp/droid-target.settings.json';
const readPaths: string[] = [];
try {
profileReader.listApiProfiles = () => ({
profiles: [
{
name: 'claude-profile',
settingsPath: claudePath,
isConfigured: true,
configSource: 'legacy',
target: 'claude',
},
{
name: 'droid-profile',
settingsPath: droidPath,
isConfigured: true,
configSource: 'legacy',
target: 'droid',
},
],
variants: [],
});
fs.existsSync = (filePath: string) => filePath === claudePath || filePath === droidPath;
fs.readFileSync = (filePath: string) => {
readPaths.push(filePath);
return JSON.stringify({
env: {
ANTHROPIC_AUTH_TOKEN: 'sk-test-key',
},
});
};
const result = profileMapper.loadSyncableProfiles();
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].name, 'claude-profile');
assert.deepStrictEqual(readPaths, [claudePath]);
} finally {
profileReader.listApiProfiles = originalListApiProfiles;
fs.existsSync = originalExistsSync;
fs.readFileSync = originalReadFileSync;
}
});
});
describe('generateSyncPayload', () => {
@@ -35,4 +35,49 @@ describe('api-command arg parser', () => {
expect(parsed.yes).toBe(true);
expect(parsed.name).toBe('-my-api');
});
test('parses --target for default profile target', () => {
const parsed = parseApiCommandArgs(['my-api', '--target', 'droid']);
expect(parsed.name).toBe('my-api');
expect(parsed.target).toBe('droid');
expect(parsed.errors).toEqual([]);
});
test('parses --target=value for default profile target', () => {
const parsed = parseApiCommandArgs(['my-api', '--target=droid']);
expect(parsed.name).toBe('my-api');
expect(parsed.target).toBe('droid');
expect(parsed.errors).toEqual([]);
});
test('validates invalid --target values', () => {
const parsed = parseApiCommandArgs(['my-api', '--target', 'invalid-target']);
expect(parsed.target).toBeUndefined();
expect(parsed.errors).toEqual(['Invalid --target value "invalid-target". Use: claude or droid']);
});
test('collects missing-value error for --target with no value', () => {
const parsed = parseApiCommandArgs(['my-api', '--target']);
expect(parsed.target).toBeUndefined();
expect(parsed.errors).toEqual(['Missing value for --target']);
});
test('treats empty --target=value as missing value', () => {
const parsed = parseApiCommandArgs(['my-api', '--target=']);
expect(parsed.target).toBeUndefined();
expect(parsed.errors).toEqual(['Missing value for --target']);
});
test('uses last --target value when repeated', () => {
const parsed = parseApiCommandArgs(['my-api', '--target', 'claude', '--target=droid']);
expect(parsed.name).toBe('my-api');
expect(parsed.target).toBe('droid');
expect(parsed.errors).toEqual([]);
});
});
@@ -0,0 +1,51 @@
import { describe, expect, test } from 'bun:test';
import { parseProfileArgs } from '../../../src/commands/cliproxy/variant-subcommand';
describe('cliproxy variant arg parser', () => {
test('parses --target value form', () => {
const parsed = parseProfileArgs(['variant-a', '--target', 'droid']);
expect(parsed.name).toBe('variant-a');
expect(parsed.target).toBe('droid');
expect(parsed.errors).toEqual([]);
});
test('parses --target=value form', () => {
const parsed = parseProfileArgs(['variant-a', '--target=droid']);
expect(parsed.name).toBe('variant-a');
expect(parsed.target).toBe('droid');
expect(parsed.errors).toEqual([]);
});
test('collects missing value error for --target with no value', () => {
const parsed = parseProfileArgs(['variant-a', '--target']);
expect(parsed.target).toBeUndefined();
expect(parsed.errors).toEqual(['Missing value for --target']);
});
test('uses last --target value when repeated', () => {
const parsed = parseProfileArgs(['variant-a', '--target', 'claude', '--target=droid']);
expect(parsed.target).toBe('droid');
expect(parsed.errors).toEqual([]);
});
test('supports option terminator for variant names that start with dash', () => {
const parsed = parseProfileArgs(['--yes', '--', '-variant-a']);
expect(parsed.yes).toBe(true);
expect(parsed.name).toBe('-variant-a');
expect(parsed.errors).toEqual([]);
});
test('does not parse flags after option terminator', () => {
const parsed = parseProfileArgs(['--', '--target', 'droid']);
expect(parsed.target).toBeUndefined();
expect(parsed.name).toBe('--target');
expect(parsed.errors).toEqual([]);
});
});
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { handleHelpCommand } from '../../../src/commands/help-command';
function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, '');
}
describe('help command parity', () => {
const originalLog = console.log;
afterEach(() => {
console.log = originalLog;
});
test('root help documents cliproxy provider filter under quota command', async () => {
const lines: string[] = [];
console.log = (...args: unknown[]) => {
lines.push(args.map((arg) => String(arg)).join(' '));
};
await handleHelpCommand();
const rendered = stripAnsi(lines.join('\n'));
expect(rendered.includes('ccs cliproxy status [provider]')).toBe(false);
expect(rendered.includes('ccs cliproxy status')).toBe(true);
expect(rendered.includes('ccs cliproxy quota --provider <name>')).toBe(true);
});
});
+84 -1
View File
@@ -3,7 +3,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { loadMigrationCheckData, migrate } from '../../../src/config/migration-manager';
import { saveUnifiedConfig } from '../../../src/config/unified-config-loader';
import { loadUnifiedConfig, saveUnifiedConfig } from '../../../src/config/unified-config-loader';
import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types';
describe('migration-manager legacy kimi compatibility', () => {
@@ -132,4 +132,87 @@ describe('migration-manager legacy kimi compatibility', () => {
const checkData = loadMigrationCheckData();
expect(checkData.needsMigration).toBe(false);
});
it('migrates account context metadata from profiles.json', async () => {
fs.writeFileSync(
path.join(ccsDir, 'profiles.json'),
JSON.stringify(
{
default: 'work',
profiles: {
work: {
type: 'account',
created: '2026-02-01T00:00:00.000Z',
last_used: null,
context_mode: 'shared',
context_group: 'sprint-a',
},
personal: {
type: 'account',
created: '2026-02-02T00:00:00.000Z',
last_used: null,
},
},
},
null,
2
)
);
const result = await migrate(false);
expect(result.success).toBe(true);
const unified = loadUnifiedConfig();
expect(unified).toBeTruthy();
expect(unified?.accounts.work.context_mode).toBe('shared');
expect(unified?.accounts.work.context_group).toBe('sprint-a');
expect(unified?.accounts.work.continuity_mode).toBe('standard');
expect(unified?.accounts.personal.context_mode).toBe('isolated');
expect(unified?.accounts.personal.context_group).toBeUndefined();
expect(unified?.accounts.personal.continuity_mode).toBeUndefined();
});
it('normalizes valid legacy shared groups and drops invalid ones during migration', async () => {
fs.writeFileSync(
path.join(ccsDir, 'profiles.json'),
JSON.stringify(
{
default: 'work',
profiles: {
work: {
type: 'account',
created: '2026-02-01T00:00:00.000Z',
last_used: null,
context_mode: 'shared',
context_group: 'Sprint-A',
},
broken: {
type: 'account',
created: '2026-02-02T00:00:00.000Z',
last_used: null,
context_mode: 'shared',
context_group: '###',
},
},
},
null,
2
)
);
const result = await migrate(false);
expect(result.success).toBe(true);
expect(
result.warnings.some((warning) =>
warning.includes('Skipped invalid context group for account "broken"')
)
).toBe(true);
const unified = loadUnifiedConfig();
expect(unified?.accounts.work.context_group).toBe('sprint-a');
expect(unified?.accounts.broken.context_mode).toBe('shared');
expect(unified?.accounts.broken.context_group).toBeUndefined();
expect(unified?.accounts.work.continuity_mode).toBe('standard');
expect(unified?.accounts.broken.continuity_mode).toBe('standard');
});
});
+39
View File
@@ -41,6 +41,41 @@ describe('model-pricing', () => {
// Should match via normalization
});
it('should resolve lowercase MiniMax model IDs to custom pricing', () => {
const pricing = getModelPricing('minimax-m2.5');
expect(pricing.inputPerMillion).toBe(0.3);
expect(pricing.outputPerMillion).toBe(1.2);
});
it('should resolve provider-prefixed MiniMax model IDs to custom pricing', () => {
const pricing = getModelPricing('minimax/MiniMax-M2.5');
expect(pricing.inputPerMillion).toBe(0.3);
expect(pricing.outputPerMillion).toBe(1.2);
});
it('should use updated MiniMax-M2.1-lightning input pricing', () => {
const pricing = getModelPricing('MiniMax-M2.1-lightning');
expect(pricing.inputPerMillion).toBe(0.6);
});
it('should not use fallback pricing for known Qwen catalog IDs', () => {
const fallback = getModelPricing('unknown-model-xyz');
const catalogIds = ['qwen3-235b', 'qwen3-vl-plus', 'qwen3-32b'];
for (const model of catalogIds) {
const pricing = getModelPricing(model);
expect(pricing).not.toEqual(fallback);
}
});
it('should map qwen3-coder to deterministic custom pricing', () => {
const pricing = getModelPricing('qwen3-coder');
const canonical = getModelPricing('qwen3-coder-plus');
expect(pricing).toEqual(canonical);
expect(pricing).not.toEqual(getModelPricing('unknown-model-xyz'));
});
it('should return different pricing for different model tiers', () => {
const sonnet = getModelPricing('claude-sonnet-4-5');
const opus = getModelPricing('claude-opus-4-5-20251101');
@@ -134,6 +169,10 @@ describe('model-pricing', () => {
expect(hasCustomPricing('glm-4.6')).toBe(true);
});
it('should return true for deterministic qwen3-coder alias', () => {
expect(hasCustomPricing('qwen3-coder')).toBe(true);
});
it('should return false for unknown models', () => {
expect(hasCustomPricing('unknown-model-xyz')).toBe(false);
});
+130
View File
@@ -3,6 +3,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import SharedManager from '../../src/management/shared-manager';
import InstanceManager from '../../src/management/instance-manager';
import type { AccountContextPolicy } from '../../src/auth/account-context';
function getTestCcsDir(): string {
@@ -12,6 +13,12 @@ function getTestCcsDir(): string {
return path.join(path.resolve(process.env.CCS_HOME), '.ccs');
}
function createDirectorySymlink(targetPath: string, linkPath: string): void {
const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir';
const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath;
fs.symlinkSync(linkTarget, linkPath, symlinkType);
}
describe('SharedManager context policy', () => {
let tempRoot = '';
let originalHome: string | undefined;
@@ -57,6 +64,20 @@ describe('SharedManager context policy', () => {
return { instancePath, ccsDir };
}
async function applyPolicyWithContinuity(
policy: AccountContextPolicy
): Promise<{ instancePath: string; ccsDir: string }> {
const ccsDir = getTestCcsDir();
const instancePath = path.join(ccsDir, 'instances', 'work');
fs.mkdirSync(instancePath, { recursive: true });
const manager = new SharedManager();
await manager.syncProjectContext(instancePath, policy);
await manager.syncAdvancedContinuityArtifacts(instancePath, policy);
return { instancePath, ccsDir };
}
it('keeps projects isolated by default', async () => {
const { instancePath } = await applyPolicy({ mode: 'isolated' });
const projectsPath = path.join(instancePath, 'projects');
@@ -118,4 +139,113 @@ describe('SharedManager context policy', () => {
expect(fs.existsSync(projectFile)).toBe(true);
expect(fs.readFileSync(projectFile, 'utf8')).toBe('shared history');
});
it('serializes concurrent context sync for the same profile', async () => {
const instanceMgr = new InstanceManager();
const jobs = Array.from({ length: 6 }, () =>
instanceMgr.ensureInstance('work', { mode: 'shared', group: 'sprint-a' })
);
await Promise.all(jobs);
const ccsDir = getTestCcsDir();
const projectsPath = path.join(ccsDir, 'instances', 'work', 'projects');
const stats = fs.lstatSync(projectsPath);
expect(stats.isDirectory() || stats.isSymbolicLink()).toBe(true);
});
it('links advanced continuity artifacts for shared deeper mode', async () => {
const { instancePath, ccsDir } = await applyPolicyWithContinuity({
mode: 'shared',
group: 'sprint-a',
continuityMode: 'deeper',
});
const artifactPath = path.join(instancePath, 'session-env');
const targetFile = path.join(
ccsDir,
'shared',
'context-groups',
'sprint-a',
'continuity',
'session-env',
'session.json'
);
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
fs.writeFileSync(targetFile, '{"id":"shared"}', 'utf8');
expect(fs.lstatSync(artifactPath).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(path.join(artifactPath, 'session.json'), 'utf8')).toContain('shared');
});
it('detaches advanced continuity artifacts when moving from deeper to standard shared mode', async () => {
const { instancePath, ccsDir } = await applyPolicyWithContinuity({
mode: 'shared',
group: 'sprint-a',
continuityMode: 'deeper',
});
const sharedTodo = path.join(
ccsDir,
'shared',
'context-groups',
'sprint-a',
'continuity',
'todos',
'todo.md'
);
fs.mkdirSync(path.dirname(sharedTodo), { recursive: true });
fs.writeFileSync(sharedTodo, '- shared todo', 'utf8');
const manager = new SharedManager();
await manager.syncAdvancedContinuityArtifacts(instancePath, {
mode: 'shared',
group: 'sprint-a',
continuityMode: 'standard',
});
const localTodoDir = path.join(instancePath, 'todos');
expect(fs.lstatSync(localTodoDir).isDirectory()).toBe(true);
expect(fs.readFileSync(path.join(localTodoDir, 'todo.md'), 'utf8')).toContain('shared todo');
});
it('skips merge when projects symlink target is outside canonical CCS roots', async () => {
const ccsDir = getTestCcsDir();
const instancePath = path.join(ccsDir, 'instances', 'work');
const projectsPath = path.join(instancePath, 'projects');
const unsafeProjectsPath = path.join(ccsDir, 'shared', 'context-groups-evil', 'projects');
const unsafeFile = path.join(unsafeProjectsPath, '-tmp-project', 'notes.md');
fs.mkdirSync(path.dirname(unsafeFile), { recursive: true });
fs.writeFileSync(unsafeFile, 'unsafe source', 'utf8');
fs.mkdirSync(instancePath, { recursive: true });
createDirectorySymlink(unsafeProjectsPath, projectsPath);
const manager = new SharedManager();
await manager.syncProjectContext(instancePath, { mode: 'isolated' });
expect(fs.lstatSync(projectsPath).isDirectory()).toBe(true);
expect(fs.existsSync(path.join(projectsPath, '-tmp-project', 'notes.md'))).toBe(false);
});
it('does not detach project memory symlink from lookalike shared path prefixes', async () => {
const ccsDir = getTestCcsDir();
const instancePath = path.join(ccsDir, 'instances', 'work');
const projectPath = path.join(instancePath, 'projects', '-tmp-project');
const memoryPath = path.join(projectPath, 'memory');
const unsafeMemoryTarget = path.join(ccsDir, 'shared', 'memory-evil', '-tmp-project');
const unsafeMemoryFile = path.join(unsafeMemoryTarget, 'MEMORY.md');
fs.mkdirSync(path.dirname(unsafeMemoryFile), { recursive: true });
fs.writeFileSync(unsafeMemoryFile, 'unsafe memory', 'utf8');
fs.mkdirSync(projectPath, { recursive: true });
createDirectorySymlink(unsafeMemoryTarget, memoryPath);
const manager = new SharedManager();
await manager.syncProjectContext(instancePath, { mode: 'isolated' });
expect(fs.lstatSync(memoryPath).isSymbolicLink()).toBe(true);
});
});
+2 -2
View File
@@ -5,9 +5,9 @@ import { describe, it, expect } from 'bun:test';
import { DroidAdapter } from '../../../src/targets/droid-adapter';
describe('DroidAdapter.buildArgs', () => {
it('builds droid model args for valid profile names', () => {
it('passes user args without model injection for valid profile names', () => {
const adapter = new DroidAdapter();
expect(adapter.buildArgs('gemini_01', ['--help'])).toEqual(['-m', 'custom:ccs-gemini_01', '--help']);
expect(adapter.buildArgs('gemini_01', ['--help'])).toEqual(['--help']);
});
it('rejects unsafe profile names', () => {
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'bun:test';
import {
DroidCommandRouterError,
routeDroidCommandArgs,
} from '../../../src/targets/droid-command-router';
describe('droid-command-router', () => {
it('keeps interactive mode for plain profile launches', () => {
const route = routeDroidCommandArgs([]);
expect(route.mode).toBe('interactive');
expect(route.argsForDroid).toEqual([]);
expect(route.autoPrependedExec).toBe(false);
expect(route.duplicateReasoningDisplays).toEqual([]);
});
it('keeps explicit droid subcommands untouched', () => {
const route = routeDroidCommandArgs(['mcp', '--help']);
expect(route.mode).toBe('command');
expect(route.command).toBe('mcp');
expect(route.argsForDroid).toEqual(['mcp', '--help']);
expect(route.autoPrependedExec).toBe(false);
expect(route.duplicateReasoningDisplays).toEqual([]);
});
it('auto-prepends exec for exec-only flags provided after profile', () => {
const route = routeDroidCommandArgs(['--skip-permissions-unsafe']);
expect(route.mode).toBe('command');
expect(route.command).toBe('exec');
expect(route.argsForDroid).toEqual(['exec', '--skip-permissions-unsafe']);
expect(route.autoPrependedExec).toBe(true);
expect(route.duplicateReasoningDisplays).toEqual([]);
});
it('does not auto-prepend exec for root help flag', () => {
const route = routeDroidCommandArgs(['--help']);
expect(route.mode).toBe('interactive');
expect(route.argsForDroid).toEqual(['--help']);
expect(route.autoPrependedExec).toBe(false);
expect(route.duplicateReasoningDisplays).toEqual([]);
});
it('normalizes --effort alias to --reasoning-effort for explicit exec', () => {
const route = routeDroidCommandArgs(['exec', '--effort', 'xhigh', 'fix test flake']);
expect(route.mode).toBe('command');
expect(route.command).toBe('exec');
expect(route.argsForDroid).toEqual(['exec', '--reasoning-effort', 'xhigh', 'fix test flake']);
expect(route.reasoningSourceDisplay).toBe('--effort xhigh');
});
it('normalizes --thinking alias when exec is auto-prepended', () => {
const route = routeDroidCommandArgs(['--auto', 'high', '--thinking=medium', 'summarize logs']);
expect(route.mode).toBe('command');
expect(route.command).toBe('exec');
expect(route.argsForDroid).toEqual([
'exec',
'--auto',
'high',
'--reasoning-effort',
'medium',
'summarize logs',
]);
expect(route.autoPrependedExec).toBe(true);
expect(route.reasoningSourceDisplay).toBe('--thinking=medium');
});
it('still auto-prepends exec when --effort appears before exec-only flags', () => {
const route = routeDroidCommandArgs([
'--effort',
'xhigh',
'--skip-permissions-unsafe',
'fix flaky test',
]);
expect(route.mode).toBe('command');
expect(route.command).toBe('exec');
expect(route.argsForDroid).toEqual([
'exec',
'--reasoning-effort',
'xhigh',
'--skip-permissions-unsafe',
'fix flaky test',
]);
expect(route.autoPrependedExec).toBe(true);
expect(route.reasoningSourceDisplay).toBe('--effort xhigh');
});
it('auto-prepends exec for non-ambiguous short exec flags', () => {
const route = routeDroidCommandArgs(['-m', 'custom:gpt-5.3-codex', 'fix flaky test']);
expect(route.mode).toBe('command');
expect(route.command).toBe('exec');
expect(route.argsForDroid).toEqual(['exec', '-m', 'custom:gpt-5.3-codex', 'fix flaky test']);
expect(route.autoPrependedExec).toBe(true);
});
it('routes -r to exec when value matches reasoning effort level', () => {
const route = routeDroidCommandArgs(['-r', 'high', 'summarize logs']);
expect(route.mode).toBe('command');
expect(route.command).toBe('exec');
expect(route.argsForDroid).toEqual(['exec', '--reasoning-effort', 'high', 'summarize logs']);
expect(route.autoPrependedExec).toBe(true);
});
it('keeps interactive mode for ambiguous -r resume-style usage', () => {
const route = routeDroidCommandArgs(['-r', 'session-1234']);
expect(route.mode).toBe('interactive');
expect(route.argsForDroid).toEqual(['-r', 'session-1234']);
expect(route.autoPrependedExec).toBe(false);
});
it('dedupes mixed reasoning flags with first occurrence precedence', () => {
const route = routeDroidCommandArgs([
'exec',
'--reasoning-effort',
'high',
'--thinking',
'low',
'--effort=xhigh',
'summarize logs',
]);
expect(route.mode).toBe('command');
expect(route.command).toBe('exec');
expect(route.argsForDroid).toEqual(['exec', '--reasoning-effort', 'high', 'summarize logs']);
expect(route.reasoningSourceDisplay).toBe('--reasoning-effort high');
expect(route.duplicateReasoningDisplays).toEqual(['--thinking low', '--effort=xhigh']);
});
it('throws for missing reasoning value in command mode (alias)', () => {
expect(() => routeDroidCommandArgs(['exec', '--effort'])).toThrow(DroidCommandRouterError);
});
it('throws for missing reasoning value in command mode (native)', () => {
expect(() => routeDroidCommandArgs(['exec', '--reasoning-effort'])).toThrow(
DroidCommandRouterError
);
});
it('records malformed duplicate reasoning flags when first value is already selected', () => {
const route = routeDroidCommandArgs([
'exec',
'--thinking',
'medium',
'--reasoning-effort',
'--skip-permissions-unsafe',
'summarize logs',
]);
expect(route.argsForDroid).toEqual([
'exec',
'--reasoning-effort',
'medium',
'--skip-permissions-unsafe',
'summarize logs',
]);
expect(route.duplicateReasoningDisplays).toEqual(['--reasoning-effort <missing-value>']);
});
});
@@ -0,0 +1,173 @@
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';
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('droid command routing integration', () => {
let tmpHome: string;
let ccsDir: string;
let settingsPath: string;
let configPath: string;
let fakeDroidPath: string;
let droidArgsLogPath: string;
let baseEnv: NodeJS.ProcessEnv;
beforeEach(() => {
if (process.platform === 'win32') {
return;
}
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-route-it-'));
ccsDir = path.join(tmpHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
settingsPath = path.join(ccsDir, 'myglm.settings.json');
configPath = path.join(ccsDir, 'config.json');
fakeDroidPath = path.join(tmpHome, 'fake-droid.js');
droidArgsLogPath = path.join(tmpHome, 'droid-args.json');
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://example.invalid/anthropic',
ANTHROPIC_AUTH_TOKEN: 'test-token',
ANTHROPIC_MODEL: 'gpt-5.3-codex',
CCS_DROID_PROVIDER: 'openai',
},
},
null,
2
)
);
fs.writeFileSync(
configPath,
JSON.stringify(
{
profiles: {
myglm: settingsPath,
},
},
null,
2
)
);
fs.writeFileSync(
fakeDroidPath,
`#!/usr/bin/env node
const fs = require('fs');
const out = process.env.CCS_TEST_DROID_ARGS_OUT;
if (!out) process.exit(2);
fs.writeFileSync(out, JSON.stringify(process.argv.slice(2)));
process.exit(0);
`,
{ encoding: 'utf8', mode: 0o755 }
);
fs.chmodSync(fakeDroidPath, 0o755);
baseEnv = {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_DROID_PATH: fakeDroidPath,
CCS_TEST_DROID_ARGS_OUT: droidArgsLogPath,
};
});
afterEach(() => {
if (process.platform === 'win32') {
return;
}
fs.rmSync(tmpHome, { recursive: true, force: true });
});
it('auto-routes exec-only long flags to droid exec from main ccs flow', () => {
if (process.platform === 'win32') return;
const result = runCcs(
['myglm', '--target', 'droid', '--skip-permissions-unsafe', 'fix failing tests'],
baseEnv
);
expect(result.status).toBe(0);
const routedArgs = JSON.parse(fs.readFileSync(droidArgsLogPath, 'utf8')) as string[];
expect(routedArgs).toEqual(['exec', '--skip-permissions-unsafe', 'fix failing tests']);
});
it('auto-routes non-ambiguous short exec flags', () => {
if (process.platform === 'win32') return;
const result = runCcs(
['myglm', '--target', 'droid', '-m', 'custom:gpt-5.3-codex', 'fix failing tests'],
baseEnv
);
expect(result.status).toBe(0);
const routedArgs = JSON.parse(fs.readFileSync(droidArgsLogPath, 'utf8')) as string[];
expect(routedArgs).toEqual(['exec', '-m', 'custom:gpt-5.3-codex', 'fix failing tests']);
});
it('dedupes reasoning flags with first occurrence precedence in exec mode', () => {
if (process.platform === 'win32') return;
const result = runCcs(
[
'myglm',
'--target',
'droid',
'exec',
'--reasoning-effort',
'high',
'--thinking',
'low',
'summarize logs',
],
baseEnv
);
expect(result.status).toBe(0);
expect(result.stderr).toContain('Multiple reasoning flags detected');
const routedArgs = JSON.parse(fs.readFileSync(droidArgsLogPath, 'utf8')) as string[];
expect(routedArgs).toEqual(['exec', '--reasoning-effort', 'high', 'summarize logs']);
});
it('fails fast for malformed reasoning alias in command mode', () => {
if (process.platform === 'win32') return;
const result = runCcs(['myglm', '--target', 'droid', 'exec', '--effort'], baseEnv);
expect(result.status).toBe(1);
expect(result.stderr).toContain('--effort requires a value');
expect(fs.existsSync(droidArgsLogPath)).toBe(true);
const probeArgs = JSON.parse(fs.readFileSync(droidArgsLogPath, 'utf8')) as string[];
// Droid binary is still invoked once for version preflight (`--version`) before routing.
expect(probeArgs).toEqual(['--version']);
});
});
@@ -32,6 +32,25 @@ describe('droid-config-manager', () => {
});
describe('upsertCcsModel', () => {
it('should return a selector reference for the managed model', async () => {
const ref = await upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy-key',
provider: 'anthropic',
});
expect(ref.profile).toBe('gemini');
expect(ref.selectorAlias).toBe('CCS-gemini-0');
expect(ref.selector).toBe('custom:CCS-gemini-0');
expect(ref.index).toBe(0);
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.model).toBe('custom:CCS-gemini-0');
});
it('should create settings.json with customModels', async () => {
await upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
@@ -48,6 +67,7 @@ describe('droid-config-manager', () => {
expect(settings.customModels).toHaveLength(1);
expect(settings.customModels[0].displayName).toBe('CCS gemini');
expect(settings.customModels[0].baseUrl).toBe('http://localhost:8317');
expect(settings.model).toBe('custom:CCS-gemini-0');
});
it('should update existing entry on second upsert', async () => {
@@ -74,6 +94,79 @@ describe('droid-config-manager', () => {
expect(settings.customModels[0].baseUrl).toBe('http://localhost:8318');
});
it('should persist generic provider reasoning_effort from override', async () => {
await upsertCcsModel('glm', {
model: 'glm-4.7',
displayName: 'CCS glm',
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
apiKey: 'glm-key',
provider: 'generic-chat-completion-api',
reasoningOverride: 'high',
});
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.customModels[0].extraArgs?.reasoning_effort).toBe('high');
expect(settings.customModels[0].extraArgs?.reasoning).toBeUndefined();
expect(settings.customModels[0].extraArgs?.thinking).toBeUndefined();
});
it('should persist openai provider reasoning.effort from --effort alias override', async () => {
await upsertCcsModel('codex', {
model: 'gpt-5.2',
displayName: 'CCS codex',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'openai-key',
provider: 'openai',
reasoningOverride: 'xhigh',
});
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.customModels[0].extraArgs?.reasoning?.effort).toBe('xhigh');
expect(settings.customModels[0].extraArgs?.reasoning_effort).toBeUndefined();
});
it('should persist anthropic thinking budget from numeric override', async () => {
await upsertCcsModel('agy', {
model: 'claude-opus-4-5-thinking',
displayName: 'CCS agy',
baseUrl: 'https://api.anthropic.com',
apiKey: 'anthropic-key',
provider: 'anthropic',
reasoningOverride: 40960,
});
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.customModels[0].extraArgs?.thinking?.type).toBe('enabled');
expect(settings.customModels[0].extraArgs?.thinking?.budget_tokens).toBe(40960);
});
it('should clear prior reasoning config when override disables thinking', async () => {
await upsertCcsModel('glm', {
model: 'glm-4.7',
displayName: 'CCS glm',
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
apiKey: 'glm-key',
provider: 'generic-chat-completion-api',
reasoningOverride: 'high',
});
await upsertCcsModel('glm', {
model: 'glm-4.7',
displayName: 'CCS glm',
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
apiKey: 'glm-key',
provider: 'generic-chat-completion-api',
reasoningOverride: 'off',
});
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.customModels[0].extraArgs).toBeUndefined();
});
it('should preserve user entries', async () => {
// Create existing settings with user's own custom model
const factoryDir = path.join(tmpDir, '.factory');
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'bun:test';
import {
normalizeDroidProvider,
inferDroidProviderFromBaseUrl,
inferDroidProviderFromModel,
resolveDroidProvider,
} from '../../../src/targets/droid-provider';
describe('droid-provider', () => {
describe('normalizeDroidProvider', () => {
it('accepts canonical provider names', () => {
expect(normalizeDroidProvider('anthropic')).toBe('anthropic');
expect(normalizeDroidProvider('openai')).toBe('openai');
expect(normalizeDroidProvider('generic-chat-completion-api')).toBe(
'generic-chat-completion-api'
);
});
it('normalizes compatibility aliases', () => {
expect(normalizeDroidProvider('anthropic-compatible')).toBe('anthropic');
expect(normalizeDroidProvider('openai-compatible')).toBe('generic-chat-completion-api');
});
it('returns null for unknown values', () => {
expect(normalizeDroidProvider('')).toBeNull();
expect(normalizeDroidProvider('unsupported')).toBeNull();
expect(normalizeDroidProvider(undefined)).toBeNull();
});
});
describe('inferDroidProviderFromBaseUrl', () => {
it('detects anthropic-compatible endpoints', () => {
expect(inferDroidProviderFromBaseUrl('https://api.anthropic.com')).toBe('anthropic');
expect(inferDroidProviderFromBaseUrl('https://api.z.ai/api/anthropic')).toBe('anthropic');
});
it('detects openai official endpoints', () => {
expect(inferDroidProviderFromBaseUrl('https://api.openai.com/v1')).toBe('openai');
});
it('detects generic openai-chat-compatible endpoints', () => {
expect(inferDroidProviderFromBaseUrl('https://openrouter.ai/api/v1')).toBe(
'generic-chat-completion-api'
);
expect(inferDroidProviderFromBaseUrl('https://api.deepinfra.com/v1/openai')).toBe(
'generic-chat-completion-api'
);
});
it('detects localhost openai-compatible /v1 endpoints', () => {
expect(inferDroidProviderFromBaseUrl('http://127.0.0.1:1234/v1')).toBe(
'generic-chat-completion-api'
);
expect(inferDroidProviderFromBaseUrl('http://localhost:8317/v1/chat/completions')).toBe(
'generic-chat-completion-api'
);
expect(inferDroidProviderFromBaseUrl('http://[::1]:8317/v1')).toBe(
'generic-chat-completion-api'
);
});
});
describe('inferDroidProviderFromModel', () => {
it('detects anthropic model naming', () => {
expect(inferDroidProviderFromModel('claude-sonnet-4-5-20250929')).toBe('anthropic');
});
it('detects openai model naming', () => {
expect(inferDroidProviderFromModel('gpt-5-codex')).toBe('openai');
});
it('detects generic openai-compatible model families', () => {
expect(inferDroidProviderFromModel('qwen3-coder-plus')).toBe('generic-chat-completion-api');
expect(inferDroidProviderFromModel('deepseek-v3.1')).toBe('generic-chat-completion-api');
expect(inferDroidProviderFromModel('kimi-k2')).toBe('generic-chat-completion-api');
});
});
describe('resolveDroidProvider', () => {
it('prefers explicit provider', () => {
expect(
resolveDroidProvider({
provider: 'generic-chat-completion-api',
baseUrl: 'https://api.anthropic.com',
})
).toBe('generic-chat-completion-api');
});
it('falls back to URL inference when provider hint is missing', () => {
expect(resolveDroidProvider({ baseUrl: 'https://api.openai.com/v1' })).toBe('openai');
});
it('defaults to anthropic for legacy profiles without clear signal', () => {
expect(resolveDroidProvider({ baseUrl: 'http://127.0.0.1:8317' })).toBe('anthropic');
expect(resolveDroidProvider({})).toBe('anthropic');
});
});
});
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'bun:test';
import {
DroidReasoningFlagError,
resolveDroidReasoningRuntime,
} from '../../../src/targets/droid-reasoning-runtime';
describe('droid-reasoning-runtime', () => {
it('extracts --thinking and strips CCS reasoning flags from args', () => {
const runtime = resolveDroidReasoningRuntime(['--thinking', 'high', '--verbose'], undefined);
expect(runtime.reasoningOverride).toBe('high');
expect(runtime.sourceFlag).toBe('--thinking');
expect(runtime.argsWithoutReasoningFlags).toEqual(['--verbose']);
});
it('extracts --effort alias and strips inline value', () => {
const runtime = resolveDroidReasoningRuntime(['--effort=xhigh', '--help'], undefined);
expect(runtime.reasoningOverride).toBe('xhigh');
expect(runtime.sourceFlag).toBe('--effort');
expect(runtime.argsWithoutReasoningFlags).toEqual(['--help']);
});
it('uses CCS_THINKING env fallback when no flag is provided', () => {
const runtime = resolveDroidReasoningRuntime(['--verbose'], 'medium');
expect(runtime.reasoningOverride).toBe('medium');
expect(runtime.sourceFlag).toBeUndefined();
expect(runtime.argsWithoutReasoningFlags).toEqual(['--verbose']);
});
it('throws on missing reasoning flag value', () => {
expect(() => resolveDroidReasoningRuntime(['--thinking'], undefined)).toThrow(
DroidReasoningFlagError
);
});
});
+60 -5
View File
@@ -120,14 +120,21 @@ describe('DroidAdapter', () => {
expect(adapter.supportsProfileType('default')).toBe(true);
});
it('should NOT support cliproxy and copilot profile types', () => {
expect(adapter.supportsProfileType('cliproxy')).toBe(false);
it('should support cliproxy and NOT support copilot profile type', () => {
expect(adapter.supportsProfileType('cliproxy')).toBe(true);
expect(adapter.supportsProfileType('copilot')).toBe(false);
});
it('should build args with -m custom:ccs- prefix', () => {
const args = adapter.buildArgs('gemini', ['--verbose']);
expect(args).toEqual(['-m', 'custom:ccs-gemini', '--verbose']);
it('should keep interactive args clean (no model argv injection)', () => {
const isolatedAdapter = new DroidAdapter();
const args = isolatedAdapter.buildArgs('gemini', ['--verbose']);
expect(args).toEqual(['--verbose']);
});
it('should not queue model selector as prompt when no user args', () => {
const isolatedAdapter = new DroidAdapter();
const args = isolatedAdapter.buildArgs('codex', []);
expect(args).toEqual([]);
});
it('should build minimal env (no ANTHROPIC_ vars)', () => {
@@ -183,4 +190,52 @@ describe('DroidAdapter', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('prepareCredentials should persist reasoning override into Droid extraArgs', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-adapter-reasoning-test-'));
const originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpDir;
try {
await adapter.prepareCredentials({
profile: 'codex',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'dummy-key',
model: 'gpt-5.2',
provider: 'openai',
reasoningOverride: 'high',
});
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.customModels?.[0]?.extraArgs?.reasoning?.effort).toBe('high');
} finally {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it('buildArgs should use selector returned from Droid settings entry', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-selector-test-'));
const originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpDir;
try {
const isolatedAdapter = new DroidAdapter();
await isolatedAdapter.prepareCredentials({
profile: 'gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy-key',
model: 'claude-sonnet-4-5-20250929',
});
const args = isolatedAdapter.buildArgs('gemini', ['--verbose']);
expect(args).toEqual(['--verbose']);
} finally {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
@@ -37,6 +37,11 @@ describe('resolveTargetType', () => {
expect(resolveTargetType([], { target: 'droid' })).toBe('droid');
});
it('should fallback to claude when persisted profile target is invalid', () => {
process.argv = ['node', 'ccs'];
expect(resolveTargetType([], { target: 'invalid-target' as never })).toBe('claude');
});
it('should prioritize --target flag over profile config', () => {
process.argv = ['node', 'ccs'];
expect(resolveTargetType(['--target', 'claude'], { target: 'droid' })).toBe('claude');
@@ -0,0 +1,332 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import express from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Server } from 'http';
import accountRoutes from '../../../src/web-server/routes/account-routes';
import ProfileRegistry from '../../../src/auth/profile-registry';
import { InstanceManager } from '../../../src/management/instance-manager';
async function getJson<T>(baseUrl: string, routePath: string): Promise<T> {
const response = await fetch(`${baseUrl}${routePath}`);
expect(response.status).toBe(200);
return (await response.json()) as T;
}
async function deletePath(baseUrl: string, routePath: string): Promise<Response> {
return fetch(`${baseUrl}${routePath}`, { method: 'DELETE' });
}
async function putJson(baseUrl: string, routePath: string, body: unknown): Promise<Response> {
return fetch(`${baseUrl}${routePath}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
describe('web-server account-routes context normalization', () => {
let server: Server;
let baseUrl = '';
let tempHome = '';
let originalCcsHome: string | undefined;
let originalCcsUnified: string | undefined;
beforeAll(async () => {
const app = express();
app.use(express.json());
app.use('/api/accounts', accountRoutes);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
const handleError = (error: Error) => reject(error);
server.once('error', handleError);
server.once('listening', () => {
server.off('error', handleError);
resolve();
});
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-account-routes-context-'));
originalCcsHome = process.env.CCS_HOME;
originalCcsUnified = 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 (originalCcsUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalCcsUnified;
else delete process.env.CCS_UNIFIED_CONFIG;
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('normalizes invalid persisted account context metadata in API response', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 8',
'accounts:',
' work:',
' created: "2026-02-01T00:00:00.000Z"',
' last_used: null',
' context_mode: weird',
' context_group: "###"',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const payload = await getJson<{
accounts: Array<{
name: string;
context_mode?: string;
context_group?: string;
continuity_mode?: string;
context_inferred?: boolean;
}>;
}>(baseUrl, '/api/accounts');
const work = payload.accounts.find((account) => account.name === 'work');
expect(work).toBeTruthy();
expect(work?.context_mode).toBe('isolated');
expect(work?.context_inferred).toBe(true);
expect(work && 'context_group' in work).toBe(false);
expect(work && 'continuity_mode' in work).toBe(false);
});
it('falls back shared accounts with invalid groups to default shared group', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 8',
'accounts:',
' work:',
' created: "2026-02-01T00:00:00.000Z"',
' last_used: null',
' context_mode: shared',
' context_group: "###"',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const payload = await getJson<{
accounts: Array<{
name: string;
context_mode?: string;
context_group?: string;
continuity_mode?: string;
context_inferred?: boolean;
continuity_inferred?: boolean;
}>;
}>(baseUrl, '/api/accounts');
const work = payload.accounts.find((account) => account.name === 'work');
expect(work).toBeTruthy();
expect(work?.context_mode).toBe('shared');
expect(work?.context_group).toBe('default');
expect(work?.continuity_mode).toBe('standard');
expect(work?.context_inferred).toBe(false);
expect(work?.continuity_inferred).toBe(true);
});
it('does not delete metadata when instance deletion fails', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 8',
'accounts:',
' work:',
' created: "2026-02-01T00:00:00.000Z"',
' last_used: null',
' context_mode: shared',
' context_group: sprint-a',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const registry = new ProfileRegistry();
const originalDeleteInstance = InstanceManager.prototype.deleteInstance;
InstanceManager.prototype.deleteInstance = () => {
throw new Error('simulated instance delete failure');
};
try {
const response = await deletePath(baseUrl, '/api/accounts/work');
expect(response.status).toBe(500);
expect(registry.hasAccountUnified('work')).toBe(true);
} finally {
InstanceManager.prototype.deleteInstance = originalDeleteInstance;
}
});
it('updates existing account context metadata and normalizes shared group', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 8',
'accounts:',
' work:',
' created: "2026-02-01T00:00:00.000Z"',
' last_used: null',
' context_mode: isolated',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const response = await putJson(baseUrl, '/api/accounts/work/context', {
context_mode: 'shared',
context_group: ' Team Alpha ',
continuity_mode: 'deeper',
});
expect(response.status).toBe(200);
const payload = (await response.json()) as {
context_mode: string;
context_group: string | null;
continuity_mode?: string | null;
context_inferred?: boolean;
continuity_inferred?: boolean;
};
expect(payload.context_mode).toBe('shared');
expect(payload.context_group).toBe('team-alpha');
expect(payload.continuity_mode).toBe('deeper');
expect(payload.context_inferred).toBe(false);
expect(payload.continuity_inferred).toBe(false);
const accountsPayload = await getJson<{
accounts: Array<{
name: string;
context_mode?: string;
context_group?: string;
continuity_mode?: string;
}>;
}>(baseUrl, '/api/accounts');
const work = accountsPayload.accounts.find((account) => account.name === 'work');
expect(work?.context_mode).toBe('shared');
expect(work?.context_group).toBe('team-alpha');
expect(work?.continuity_mode).toBe('deeper');
});
it('rejects shared mode updates without context_group', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 8',
'accounts:',
' work:',
' created: "2026-02-01T00:00:00.000Z"',
' last_used: null',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const response = await putJson(baseUrl, '/api/accounts/work/context', {
context_mode: 'shared',
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toContain('context_group');
});
it('rejects context updates for CLIProxy account identifiers', async () => {
const response = await putJson(baseUrl, '/api/accounts/gemini:test/context', {
context_mode: 'shared',
context_group: 'default',
continuity_mode: 'deeper',
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toContain('CLIProxy');
});
it('rejects invalid continuity mode updates', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 8',
'accounts:',
' work:',
' created: "2026-02-01T00:00:00.000Z"',
' last_used: null',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const response = await putJson(baseUrl, '/api/accounts/work/context', {
context_mode: 'shared',
context_group: 'default',
continuity_mode: 'extreme',
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toContain('continuity_mode');
});
});
@@ -0,0 +1,234 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import express from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Server } from 'http';
import configRoutes from '../../../src/web-server/routes/config-routes';
import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types';
import { loadUnifiedConfig } from '../../../src/config/unified-config-loader';
async function putJson(baseUrl: string, routePath: string, body: unknown): Promise<Response> {
return fetch(`${baseUrl}${routePath}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
}
async function postJson(baseUrl: string, routePath: string, body?: unknown): Promise<Response> {
return fetch(`${baseUrl}${routePath}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: body === undefined ? undefined : JSON.stringify(body),
});
}
describe('web-server config-routes account context validation', () => {
let server: Server;
let baseUrl = '';
let tempHome = '';
let originalCcsHome: string | undefined;
beforeAll(async () => {
const app = express();
app.use(express.json());
app.use('/api/config', configRoutes);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
const handleError = (error: Error) => reject(error);
server.once('error', handleError);
server.once('listening', () => {
server.off('error', handleError);
resolve();
});
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-config-routes-context-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
fs.mkdirSync(path.join(tempHome, '.ccs'), { recursive: true });
});
afterEach(() => {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('rejects invalid account context_mode values', async () => {
const response = await putJson(baseUrl, '/api/config', {
version: 8,
accounts: {
work: {
created: '2026-01-01T00:00:00.000Z',
last_used: null,
context_mode: 'weird',
},
},
profiles: {},
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toContain('context_mode');
});
it('rejects context_group when mode is not shared', async () => {
const response = await putJson(baseUrl, '/api/config', {
version: 8,
accounts: {
work: {
created: '2026-01-01T00:00:00.000Z',
last_used: null,
context_mode: 'isolated',
context_group: 'sprint-a',
},
},
profiles: {},
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toContain('context_group requires context_mode=shared');
});
it('rejects continuity_mode when mode is not shared', async () => {
const response = await putJson(baseUrl, '/api/config', {
version: 8,
accounts: {
work: {
created: '2026-01-01T00:00:00.000Z',
last_used: null,
context_mode: 'isolated',
continuity_mode: 'deeper',
},
},
profiles: {},
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toContain('continuity_mode requires context_mode=shared');
});
it('rejects invalid shared continuity_mode values', async () => {
const response = await putJson(baseUrl, '/api/config', {
version: 8,
accounts: {
work: {
created: '2026-01-01T00:00:00.000Z',
last_used: null,
context_mode: 'shared',
context_group: 'team-alpha',
continuity_mode: 'extreme',
},
},
profiles: {},
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toContain('continuity_mode');
});
it('rejects invalid shared context_group names', async () => {
const response = await putJson(baseUrl, '/api/config', {
version: 8,
accounts: {
work: {
created: '2026-01-01T00:00:00.000Z',
last_used: null,
context_mode: 'shared',
context_group: '###',
},
},
profiles: {},
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toContain('context_group');
});
it('rejects whitespace-only shared context_group values', async () => {
const response = await putJson(baseUrl, '/api/config', {
version: 8,
accounts: {
work: {
created: '2026-01-01T00:00:00.000Z',
last_used: null,
context_mode: 'shared',
context_group: ' ',
},
},
profiles: {},
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toContain('requires a non-empty value');
});
it('accepts valid shared context metadata', async () => {
const config = createEmptyUnifiedConfig();
config.accounts.work = {
created: '2026-01-01T00:00:00.000Z',
last_used: null,
context_mode: 'shared',
context_group: 'Sprint-A',
continuity_mode: 'deeper',
};
const response = await putJson(baseUrl, '/api/config', config);
expect(response.status).toBe(200);
const payload = (await response.json()) as { success: boolean };
expect(payload.success).toBe(true);
const savedConfig = loadUnifiedConfig();
expect(savedConfig?.accounts.work.context_group).toBe('sprint-a');
expect(savedConfig?.accounts.work.continuity_mode).toBe('deeper');
});
it('returns alreadyMigrated when migration is not needed', async () => {
const response = await postJson(baseUrl, '/api/config/migrate');
expect(response.status).toBe(200);
const payload = (await response.json()) as {
success: boolean;
migratedFiles: string[];
warnings: string[];
alreadyMigrated?: boolean;
};
expect(payload.success).toBe(true);
expect(payload.migratedFiles).toEqual([]);
expect(payload.warnings).toEqual([]);
expect(payload.alreadyMigrated).toBe(true);
});
});
@@ -0,0 +1,234 @@
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 {
DroidRawSettingsConflictError,
DroidRawSettingsValidationError,
getDroidDashboardDiagnostics,
getDroidRawSettings,
maskApiKeyPreview,
resolveDroidConfigPaths,
saveDroidRawSettings,
summarizeDroidCustomModels,
} from '../../../src/web-server/services/droid-dashboard-service';
const testRoot = path.join(os.tmpdir(), `ccs-droid-dashboard-test-${Date.now()}`);
beforeEach(() => {
fs.mkdirSync(testRoot, { recursive: true });
process.env.CCS_HOME = testRoot;
});
afterEach(() => {
delete process.env.CCS_HOME;
if (fs.existsSync(testRoot)) {
fs.rmSync(testRoot, { recursive: true, force: true });
}
});
describe('droid-dashboard-service', () => {
it('resolves droid config paths on unix-like platforms', () => {
const resolved = resolveDroidConfigPaths({
platform: 'darwin',
env: {
CCS_HOME: '/tmp/ccs-home',
} as NodeJS.ProcessEnv,
homeDir: '/Users/tester',
});
expect(resolved.settingsPath).toBe('/tmp/ccs-home/.factory/settings.json');
expect(resolved.legacyConfigPath).toBe('/tmp/ccs-home/.factory/config.json');
expect(resolved.settingsDisplayPath).toBe('~/.factory/settings.json');
expect(resolved.legacyConfigDisplayPath).toBe('~/.factory/config.json');
});
it('resolves droid config paths on windows platforms', () => {
const resolved = resolveDroidConfigPaths({
platform: 'win32',
env: {} as NodeJS.ProcessEnv,
homeDir: 'C:/Users/test',
});
expect(resolved.settingsPath).toBe(path.join('C:/Users/test', '.factory', 'settings.json'));
expect(resolved.legacyConfigPath).toBe(path.join('C:/Users/test', '.factory', 'config.json'));
expect(resolved.legacyConfigDisplayPath).toBe('~/.factory/config.json');
});
it('masks api key preview with only suffix', () => {
expect(maskApiKeyPreview('sk-abcdefghijklmnop')).toBe('***mnop');
});
it('summarizes custom model entries with provider breakdown and ownership', () => {
const summary = summarizeDroidCustomModels([
{
displayName: 'CCS codex',
model: 'gpt-5-codex',
baseUrl: 'http://127.0.0.1:8317/v1',
apiKey: 'secret-token-1234',
provider: 'openai',
},
{
displayName: 'Factory team profile',
model: 'claude-sonnet-4-5',
baseUrl: 'https://api.anthropic.com',
apiKey: 'another-token-9999',
provider: 'anthropic',
},
{
displayName: 'bad entry',
},
]);
expect(summary.customModelCount).toBe(2);
expect(summary.ccsManagedCount).toBe(1);
expect(summary.userManagedCount).toBe(1);
expect(summary.invalidModelEntryCount).toBe(1);
expect(summary.providerBreakdown.openai).toBe(1);
expect(summary.providerBreakdown.anthropic).toBe(1);
expect(summary.customModels[0].apiKeyPreview).toBe('***1234');
});
it('supports legacy snake_case model fields in summaries', () => {
const summary = summarizeDroidCustomModels([
{
model_display_name: 'Kimi K2 Thinking Nvidia',
model: 'moonshotai/kimi-k2-thinking',
base_url: 'https://integrate.api.nvidia.com/v1',
api_key: 'legacy-token-1234',
provider: 'generic-chat-completion-api',
max_tokens: 220000,
},
]);
expect(summary.customModelCount).toBe(1);
expect(summary.invalidModelEntryCount).toBe(0);
expect(summary.providerBreakdown['generic-chat-completion-api']).toBe(1);
expect(summary.customModels[0].displayName).toBe('Kimi K2 Thinking Nvidia');
expect(summary.customModels[0].maxOutputTokens).toBe(220000);
expect(summary.customModels[0].apiKeyPreview).toBe('***1234');
});
it('returns raw settings payload for missing settings file', async () => {
const raw = await getDroidRawSettings();
expect(raw.exists).toBe(false);
expect(raw.path).toBe('~/.factory/settings.json');
expect(raw.rawText).toBe('{}');
expect(raw.settings).toBeNull();
});
it('returns parseError when settings.json is invalid JSON', async () => {
const settingsDir = path.join(testRoot, '.factory');
fs.mkdirSync(settingsDir, { recursive: true });
fs.writeFileSync(path.join(settingsDir, 'settings.json'), '{ invalid-json');
const raw = await getDroidRawSettings();
expect(raw.exists).toBe(true);
expect(raw.parseError).toBeString();
expect(raw.settings).toBeNull();
expect(raw.rawText).toContain('invalid-json');
});
it('includes structured docs links for fact-checking providers', async () => {
const diagnostics = await getDroidDashboardDiagnostics();
expect(diagnostics.docsReference.links.length).toBeGreaterThan(0);
expect(diagnostics.docsReference.providerDocs.length).toBeGreaterThan(0);
expect(diagnostics.docsReference.links.every((link) => link.url.startsWith('https://'))).toBe(
true
);
expect(
diagnostics.docsReference.providerDocs.some((doc) => doc.provider === 'anthropic')
).toBe(true);
});
it('falls back to legacy config custom_models when settings customModels is absent', async () => {
const settingsDir = path.join(testRoot, '.factory');
fs.mkdirSync(settingsDir, { recursive: true });
fs.writeFileSync(path.join(settingsDir, 'settings.json'), JSON.stringify({ model: 'custom:legacy' }));
fs.writeFileSync(
path.join(settingsDir, 'config.json'),
JSON.stringify({
custom_models: [
{
model_display_name: 'Legacy OpenAI',
model: 'gpt-5.2',
base_url: 'https://api.openai.com/v1',
api_key: 'legacy-openai-1234',
provider: 'openai',
},
],
})
);
const diagnostics = await getDroidDashboardDiagnostics();
expect(diagnostics.byok.customModelCount).toBe(1);
expect(diagnostics.byok.customModels[0].displayName).toBe('Legacy OpenAI');
expect(diagnostics.byok.customModels[0].provider).toBe('openai');
});
it('warns when settings.json uses legacy custom_models key', async () => {
const settingsDir = path.join(testRoot, '.factory');
fs.mkdirSync(settingsDir, { recursive: true });
fs.writeFileSync(
path.join(settingsDir, 'settings.json'),
JSON.stringify({
custom_models: [
{
model_display_name: 'Legacy Generic',
model: 'glm-4.7',
base_url: 'https://api.z.ai/api/coding/paas/v4',
api_key: 'legacy-zai-1234',
provider: 'generic-chat-completion-api',
},
],
})
);
const diagnostics = await getDroidDashboardDiagnostics();
expect(
diagnostics.warnings.some((warning) => warning.includes('legacy "custom_models" key'))
).toBe(true);
expect(diagnostics.byok.customModelCount).toBe(1);
});
it('saves valid raw settings content', async () => {
const result = await saveDroidRawSettings({
rawText: JSON.stringify({
model: 'custom:test-model',
customModels: [],
}),
});
const settingsPath = path.join(testRoot, '.factory', 'settings.json');
const written = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(result.success).toBe(true);
expect(result.mtime).toBeGreaterThan(0);
expect(written.model).toBe('custom:test-model');
});
it('rejects invalid JSON while saving raw settings', async () => {
await expect(saveDroidRawSettings({ rawText: '{ invalid-json' })).rejects.toThrow(
DroidRawSettingsValidationError
);
});
it('rejects stale writes with conflict error', async () => {
const settingsDir = path.join(testRoot, '.factory');
fs.mkdirSync(settingsDir, { recursive: true });
const settingsPath = path.join(settingsDir, 'settings.json');
fs.writeFileSync(settingsPath, JSON.stringify({ customModels: [] }));
await expect(
saveDroidRawSettings({
rawText: JSON.stringify({ model: 'custom:next', customModels: [] }),
expectedMtime: 1,
})
).rejects.toThrow(DroidRawSettingsConflictError);
});
});

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