mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-13 06:22:49 +00:00
Merge pull request #646 from kaitranntt/dev
feat(release): promote dev to main
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) */
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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')}`);
|
||||
|
||||
@@ -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)`));
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -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`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user