mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +00:00
Merge pull request #575 from kaitranntt/kai/feat/multi-target-droid
feat(targets): add multi-target CLI adapter system (Droid support)
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
"listr2": "^3.14.0",
|
||||
"open": "^8.4.2",
|
||||
"ora": "^5.4.1",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"ws": "^8.16.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -40,6 +41,7 @@
|
||||
"@types/express-session": "^1.18.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.19.25",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
@@ -397,10 +399,14 @@
|
||||
|
||||
"@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="],
|
||||
|
||||
"@types/proper-lockfile": ["@types/proper-lockfile@4.1.4", "", { "dependencies": { "@types/retry": "*" } }, "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ=="],
|
||||
|
||||
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
|
||||
|
||||
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
|
||||
|
||||
"@types/retry": ["@types/retry@0.12.5", "", {}, "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw=="],
|
||||
|
||||
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
|
||||
|
||||
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
|
||||
@@ -1091,6 +1097,8 @@
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
|
||||
|
||||
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
@@ -1129,6 +1137,8 @@
|
||||
|
||||
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
|
||||
|
||||
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA=="],
|
||||
|
||||
@@ -40,6 +40,9 @@ Code standards, modularization patterns, and conventions for the CCS codebase.
|
||||
|------------|---------|-------------|
|
||||
| kebab-case | `cliproxy-executor.ts` | All TypeScript/TSX files |
|
||||
| kebab-case | `profile-detector.ts` | Multi-word file names |
|
||||
| *-adapter.ts | `claude-adapter.ts`, `droid-adapter.ts` | TargetAdapter implementations |
|
||||
| *-detector.ts | `droid-detector.ts` | Binary detection logic |
|
||||
| *-manager.ts | `droid-config-manager.ts` | Config/state management |
|
||||
| PascalCase | `BinaryManager` | Class exports only |
|
||||
| camelCase | `detectProfile` | Function exports |
|
||||
|
||||
@@ -157,6 +160,84 @@ Allowed when:
|
||||
|
||||
---
|
||||
|
||||
## Target Adapter Pattern
|
||||
|
||||
The target adapter pattern enables pluggable support for multiple CLI implementations (Claude Code, Factory Droid, etc.) while preserving a unified profile system.
|
||||
|
||||
### Pattern Overview
|
||||
|
||||
**Each CLI target implements a `TargetAdapter` interface:**
|
||||
|
||||
```typescript
|
||||
interface TargetAdapter {
|
||||
readonly type: TargetType; // 'claude' | 'droid'
|
||||
readonly displayName: string; // Human-readable name
|
||||
|
||||
detectBinary(): TargetBinaryInfo | null; // Find CLI on system
|
||||
prepareCredentials(creds: TargetCredentials): Promise<void>; // Deliver credentials
|
||||
buildArgs(profile: string, userArgs: string[]): string[]; // Build CLI args
|
||||
buildEnv(creds: TargetCredentials, type: string): Env; // Build env vars
|
||||
exec(args: string[], env: Env): void; // Spawn CLI process
|
||||
supportsProfileType(type: string): boolean; // Validate profile
|
||||
}
|
||||
```
|
||||
|
||||
### Key Differences Per Target
|
||||
|
||||
| Aspect | Claude | Droid |
|
||||
|--------|--------|-------|
|
||||
| **Credential delivery** | Environment variables | Config file (~/.factory/settings.json) |
|
||||
| **Spawn args** | `claude <args>` | `droid -m custom:ccs-<profile> <args>` |
|
||||
| **Config write** | None (uses env) | `upsertCcsModel()` writes to settings |
|
||||
| **Binary detection** | `detectClaudeCli()` | `detectDroidCli()` with version check |
|
||||
|
||||
### Target Resolution Priority
|
||||
|
||||
Resolves which adapter to use via `resolveTargetType()`:
|
||||
|
||||
```
|
||||
1. --target <name> flag (highest priority)
|
||||
↓
|
||||
2. Profile config: profileConfig.target field
|
||||
↓
|
||||
3. argv[0] detection (busybox pattern):
|
||||
- ccsd → droid
|
||||
- ccs → default
|
||||
↓
|
||||
4. Fallback: 'claude' (lowest priority)
|
||||
```
|
||||
|
||||
### Registration Pattern
|
||||
|
||||
At startup, adapters self-register into the runtime registry:
|
||||
|
||||
```typescript
|
||||
// In ccs.ts or initialization
|
||||
registerTarget(new ClaudeAdapter());
|
||||
registerTarget(new DroidAdapter());
|
||||
|
||||
// Later, when executing
|
||||
const targetType = resolveTargetType(args, profileConfig);
|
||||
const adapter = getTarget(targetType);
|
||||
|
||||
await adapter.prepareCredentials(credentials);
|
||||
const spawnArgs = adapter.buildArgs(profile, userArgs);
|
||||
adapter.exec(spawnArgs, adapter.buildEnv(credentials, profileType));
|
||||
```
|
||||
|
||||
### Adding a New Target
|
||||
|
||||
To add support for a new CLI (e.g., `newcli`):
|
||||
|
||||
1. Create `src/targets/newcli-adapter.ts` implementing `TargetAdapter`
|
||||
2. Implement each required method (detection, credential delivery, spawning)
|
||||
3. Create `src/targets/newcli-detector.ts` for binary detection logic
|
||||
4. Export from `src/targets/index.ts`
|
||||
5. Register in `ccs.ts`: `registerTarget(new NewCliAdapter())`
|
||||
6. Update `TargetType` union to include `'newcli'`
|
||||
|
||||
---
|
||||
|
||||
## Monster File Splitting Methodology
|
||||
|
||||
When splitting large files (500+ lines), follow this process:
|
||||
@@ -328,6 +409,26 @@ Use ASCII box drawing for error displays:
|
||||
+=====================================+
|
||||
```
|
||||
|
||||
### Cross-Platform Adapter Spawning
|
||||
|
||||
When implementing target adapters, handle platform differences for binary spawning:
|
||||
|
||||
```typescript
|
||||
// Window shell detection (.cmd, .bat, .ps1 require shell)
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(binaryPath);
|
||||
|
||||
if (needsShell) {
|
||||
// Escape arguments and use shell: true
|
||||
const cmdString = [binaryPath, ...args].map(escapeShellArg).join(' ');
|
||||
spawn(cmdString, { shell: true, stdio: 'inherit' });
|
||||
} else {
|
||||
// Direct spawn (Unix-like, unshelled Windows executables)
|
||||
spawn(binaryPath, args, { stdio: 'inherit' });
|
||||
}
|
||||
```
|
||||
|
||||
This pattern is used in both `ClaudeAdapter` and `DroidAdapter` to ensure cross-platform consistency.
|
||||
|
||||
---
|
||||
|
||||
## React Component Standards (UI)
|
||||
|
||||
@@ -56,6 +56,16 @@ src/
|
||||
│ ├── update-command.ts # Self-update logic
|
||||
│ └── version-command.ts # Version display
|
||||
│
|
||||
├── targets/ # Multi-target adapter system (NEW)
|
||||
│ ├── index.ts # Barrel export
|
||||
│ ├── target-adapter.ts # TargetAdapter interface contract
|
||||
│ ├── target-registry.ts # Registry for runtime adapter lookup
|
||||
│ ├── target-resolver.ts # Resolution logic (flag > config > argv[0])
|
||||
│ ├── claude-adapter.ts # Claude Code CLI implementation
|
||||
│ ├── droid-adapter.ts # Factory Droid CLI implementation
|
||||
│ ├── droid-detector.ts # Droid binary detection & version checks
|
||||
│ └── droid-config-manager.ts # ~/.factory/settings.json management
|
||||
│
|
||||
├── auth/ # Authentication module
|
||||
│ ├── index.ts # Barrel export
|
||||
│ ├── commands/ # Auth-specific CLI commands
|
||||
@@ -181,6 +191,7 @@ src/
|
||||
| Category | Directories | Purpose |
|
||||
|----------|-------------|---------|
|
||||
| Core | `commands/`, `errors/` | CLI commands, error handling |
|
||||
| Targets | `targets/` | Multi-CLI adapter pattern (Claude Code, Factory Droid, extensible) |
|
||||
| Auth | `auth/`, `cliproxy/auth/` | Authentication across providers |
|
||||
| Config | `config/`, `types/` | Configuration & type definitions |
|
||||
| Providers | `cliproxy/`, `copilot/`, `glmt/` | Provider integrations (7 CLIProxy providers: gemini, codex, agy, qwen, iflow, kiro, ghcp) |
|
||||
@@ -190,6 +201,47 @@ src/
|
||||
| Services | `web-server/`, `api/` | HTTP server, API services |
|
||||
| Utilities | `utils/`, `management/` | Helpers, diagnostics |
|
||||
|
||||
### Target Adapter Module
|
||||
|
||||
The targets module provides an extensible interface for dispatching profiles to different CLI implementations.
|
||||
|
||||
**Key components:**
|
||||
|
||||
1. **TargetAdapter Interface** - Contract that each CLI implementation must fulfill:
|
||||
- `detectBinary()` - Find CLI binary on system (platform-specific)
|
||||
- `prepareCredentials()` - Deliver credentials (env vars vs config file writes)
|
||||
- `buildArgs()` - Construct target-specific argument list
|
||||
- `buildEnv()` - Construct environment for target CLI
|
||||
- `exec()` - Spawn target process (cross-platform)
|
||||
- `supportsProfileType()` - Verify profile compatibility
|
||||
|
||||
2. **Target Resolution** - Priority order:
|
||||
- `--target <cli>` flag (CLI argument)
|
||||
- Per-profile `target` field (from config.yaml)
|
||||
- `argv[0]` detection (busybox pattern: `ccsd` → droid)
|
||||
- Default: `claude`
|
||||
|
||||
3. **Implementations:**
|
||||
- **ClaudeAdapter** - Wraps existing behavior; delivers credentials via environment variables
|
||||
- **DroidAdapter** - New; writes to ~/.factory/settings.json and spawns with `-m custom:ccs-<profile>` flag
|
||||
|
||||
4. **Registry** - Map-based lookup (O(1)) for registered adapters at runtime
|
||||
|
||||
**Usage flow:**
|
||||
```
|
||||
Profile resolution (existing)
|
||||
↓
|
||||
Target resolution (via resolver.ts)
|
||||
↓
|
||||
Get adapter from registry
|
||||
↓
|
||||
Prepare credentials (adapter.prepareCredentials)
|
||||
↓
|
||||
Build args & env (adapter.buildArgs, buildEnv)
|
||||
↓
|
||||
Spawn target CLI (adapter.exec)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Source (`ui/src/`)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"sourceDirectory": "src",
|
||||
"largeFileThresholdLoc": 350,
|
||||
"typeScriptFileCount": 341,
|
||||
"locInSrc": 67708,
|
||||
"processExitReferenceCount": 164,
|
||||
"synchronousFsApiReferenceCount": 850,
|
||||
"largeFileCountOver350Loc": 55
|
||||
"typeScriptFileCount": 355,
|
||||
"locInSrc": 71353,
|
||||
"processExitReferenceCount": 185,
|
||||
"synchronousFsApiReferenceCount": 879,
|
||||
"largeFileCountOver350Loc": 56
|
||||
}
|
||||
|
||||
@@ -1,754 +0,0 @@
|
||||
# CCS System Architecture
|
||||
|
||||
Last Updated: 2026-02-04
|
||||
|
||||
High-level architecture documentation for the CCS (Claude Code Switch) system.
|
||||
|
||||
---
|
||||
|
||||
## System Overview
|
||||
|
||||
CCS is a CLI wrapper that enables seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, Kiro, GitHub Copilot, OpenRouter, Qwen, Kimi, DeepSeek). It consists of two main components:
|
||||
|
||||
1. **CLI Application** (`src/`) - Node.js TypeScript CLI
|
||||
2. **Dashboard UI** (`ui/`) - React web application served by Express
|
||||
|
||||
CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy with automatic injection for all profile types.
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| CCS System |
|
||||
+===========================================================================+
|
||||
| |
|
||||
| +------------------+ +-----------------+ +----------------+ |
|
||||
| | User Terminal | ---> | CCS CLI | ---> | Claude Code | |
|
||||
| | (ccs command) | | (src/ccs.ts) | | CLI | |
|
||||
| +------------------+ +-----------------+ +----------------+ |
|
||||
| | | |
|
||||
| v v |
|
||||
| +------------------+ +-----------------+ +----------------+ |
|
||||
| | Dashboard UI | <--> | Express | ---> | Provider APIs | |
|
||||
| | (React SPA) | | Web Server | | (Claude/GLM/ | |
|
||||
| +------------------+ +-----------------+ | Gemini/etc) | |
|
||||
| | +----------------+ |
|
||||
| v |
|
||||
| +---------------------+ |
|
||||
| | CLIProxyAPI | |
|
||||
| | (Local or Remote) | |
|
||||
| +---------------------+ |
|
||||
| |
|
||||
+===========================================================================+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Architecture
|
||||
|
||||
### CLI Layer
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| CLI Architecture |
|
||||
+===========================================================================+
|
||||
|
||||
User Input (ccs <profile> [args])
|
||||
|
|
||||
v
|
||||
+-------------+
|
||||
| ccs.ts | Entry point, command routing
|
||||
+-------------+
|
||||
|
|
||||
+---> [Version/Help/Doctor/etc.] ---> Exit
|
||||
|
|
||||
v
|
||||
+-------------+
|
||||
| Profile | Determines execution path
|
||||
| Detection |
|
||||
+-------------+
|
||||
|
|
||||
+---> [Native Claude Account] ---> execClaude()
|
||||
| |
|
||||
+---> [CLIProxy Provider] ---> execClaudeWithCLIProxy()
|
||||
| |
|
||||
+---> [GLMT Profile] ---> execClaudeWithProxy()
|
||||
|
|
||||
v
|
||||
+-------------+
|
||||
| Claude CLI | Underlying Anthropic CLI
|
||||
+-------------+
|
||||
```
|
||||
|
||||
### Profile Mechanisms (Priority Order)
|
||||
|
||||
```
|
||||
Profile Resolution
|
||||
|
|
||||
v
|
||||
1. CLIProxy Hardcoded ----+---> gemini, codex, agy, kiro, ghcp
|
||||
(OAuth-based) | Zero-config OAuth providers
|
||||
| (kiro: method-aware, ghcp: Device Code)
|
||||
|
|
||||
2. CLIProxy Variants -----+---> config.cliproxy section
|
||||
(User-defined) | Custom provider configurations
|
||||
|
|
||||
3. Settings-based --------+---> config.profiles section
|
||||
(API key profiles) | GLM, GLMT, Kimi, custom
|
||||
|
|
||||
4. Account-based ---------+---> profiles.json
|
||||
(Claude instances) | Isolated via CLAUDE_CONFIG_DIR
|
||||
|
|
||||
v
|
||||
Profile Config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module Architecture
|
||||
|
||||
### CLI Modules (`src/`)
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| CLI Module Structure |
|
||||
+===========================================================================+
|
||||
|
||||
+------------------+ +------------------+ +------------------+
|
||||
| commands/ | | auth/ | | config/ |
|
||||
|------------------| |------------------| |------------------|
|
||||
| doctor-command | | account-switcher | | unified-config- |
|
||||
| env-command | | profile-detector | | loader |
|
||||
| help-command | | commands/ | | migration-manager|
|
||||
| install-command | +------------------+ +------------------+
|
||||
| sync-command |
|
||||
| update-command |
|
||||
+------------------+
|
||||
| | |
|
||||
+-----------------------+------------------------+
|
||||
|
|
||||
v
|
||||
+------------------+ +------------------+ +------------------+
|
||||
| cliproxy/ | | copilot/ | | glmt/ |
|
||||
|------------------| |------------------| |------------------|
|
||||
| cliproxy-executor| | copilot-package- | | glmt-proxy |
|
||||
| config-generator | | manager | | delta-accumulator|
|
||||
| account-manager | | [copilot logic] | | pipeline/ |
|
||||
| quota-manager | +------------------+ +------------------+
|
||||
| quota-fetcher |
|
||||
| auth/ |
|
||||
| binary/ |
|
||||
| services/ |
|
||||
+------------------+
|
||||
| | |
|
||||
+-----------------------+------------------------+
|
||||
|
|
||||
v
|
||||
+------------------+ +------------------+ +------------------+
|
||||
| web-server/ | | utils/ | | errors/ |
|
||||
|------------------| |------------------| |------------------|
|
||||
| routes/ (15+) | | ui/ (boxes, | | error-handler |
|
||||
| health/ | | colors, | | exit-codes |
|
||||
| usage/ | | spinners) | | cleanup |
|
||||
| services/ | | websearch/ | +------------------+
|
||||
| model-pricing | | shell-executor |
|
||||
+------------------+ +------------------+
|
||||
|
||||
|
|
||||
v
|
||||
+------------------+ +------------------+
|
||||
| types/ | | management/ |
|
||||
|------------------| |------------------|
|
||||
| config.ts | | checks/ |
|
||||
| cli.ts | | repair/ |
|
||||
| delegation.ts | | [diagnostics] |
|
||||
| glmt.ts | +------------------+
|
||||
+------------------+
|
||||
```
|
||||
|
||||
### UI Modules (`ui/src/`)
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| UI Module Structure |
|
||||
+===========================================================================+
|
||||
|
||||
+------------------+
|
||||
| pages/ | Route-level components (modular directories)
|
||||
|------------------|
|
||||
| analytics/ | 8 files - usage charts, cost tracking
|
||||
| settings/ | 20 files - lazy-loaded tab sections
|
||||
| api.tsx |
|
||||
| cliproxy.tsx |
|
||||
| copilot.tsx |
|
||||
| health.tsx |
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
+------------------+ +------------------+ +------------------+
|
||||
| components/ | | contexts/ | | hooks/ |
|
||||
|------------------| |------------------| |------------------|
|
||||
| account/ | | privacy-context | | use-accounts |
|
||||
| analytics/ | | theme-context | | use-cliproxy |
|
||||
| cliproxy/ | | websocket-context| | use-health |
|
||||
| copilot/ | +------------------+ | use-profiles |
|
||||
| health/ | | use-websocket |
|
||||
| layout/ | +------------------+
|
||||
| monitoring/ | <-- auth-monitor/ (8 files), error-logs/ (6 files)
|
||||
| profiles/ |
|
||||
| setup/ |
|
||||
| shared/ |
|
||||
| ui/ (shadcn) |
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
+------------------+ +------------------+
|
||||
| lib/ | | providers/ |
|
||||
|------------------| |------------------|
|
||||
| api.ts | | websocket- |
|
||||
| model-catalogs | | provider |
|
||||
| utils.ts | +------------------+
|
||||
+------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Architecture
|
||||
|
||||
### CLI Execution Flow
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| CLI Execution Flow |
|
||||
+===========================================================================+
|
||||
|
||||
1. Parse Arguments
|
||||
|
|
||||
v
|
||||
2. Detect Profile Type
|
||||
|
|
||||
+---> Native Claude ---> 3a. Load Account Settings
|
||||
| |
|
||||
| v
|
||||
| 4a. Set CLAUDE_CONFIG_DIR
|
||||
| |
|
||||
| v
|
||||
| 5a. Spawn Claude CLI
|
||||
|
|
||||
+---> CLIProxy -------> 3b. Ensure Binary Installed
|
||||
| |
|
||||
| v
|
||||
| 4b. Generate Config
|
||||
| |
|
||||
| v
|
||||
| 5b. Start CLIProxyAPI
|
||||
| |
|
||||
| v
|
||||
| 6b. Set Proxy Env Vars
|
||||
| |
|
||||
| v
|
||||
| 7b. Spawn Claude CLI
|
||||
|
|
||||
+---> GLMT -----------> 3c. Start Embedded Proxy
|
||||
|
|
||||
v
|
||||
4c. Spawn Claude CLI
|
||||
```
|
||||
|
||||
### Dashboard Data Flow
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Dashboard Data Flow |
|
||||
+===========================================================================+
|
||||
|
||||
Browser (React SPA)
|
||||
|
|
||||
| HTTP Requests + WebSocket
|
||||
v
|
||||
Express Server (src/web-server/)
|
||||
|
|
||||
+---> /api/accounts ---> auth/account-manager
|
||||
|
|
||||
+---> /api/profiles ---> config/unified-config-loader
|
||||
|
|
||||
+---> /api/cliproxy ---> cliproxy/
|
||||
|
|
||||
+---> /api/health ----> management/checks/
|
||||
|
|
||||
+---> /api/usage -----> usage/aggregator
|
||||
|
|
||||
v
|
||||
WebSocket (Real-time)
|
||||
|
|
||||
+---> Health status updates
|
||||
+---> Auth state changes
|
||||
+---> Usage analytics
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Provider Integration Architecture
|
||||
|
||||
### CLIProxyAPI Flow
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| CLIProxyAPI Integration |
|
||||
+===========================================================================+
|
||||
|
||||
Claude CLI
|
||||
|
|
||||
| ANTHROPIC_BASE_URL = localhost:XXXX
|
||||
v
|
||||
+------------------+
|
||||
| CLIProxyAPI | Local proxy binary (CLIProxyAPIPlus for kiro/ghcp)
|
||||
| (binary) |
|
||||
+------------------+
|
||||
|
|
||||
+---> OAuth Authentication
|
||||
| |
|
||||
| +---> Authorization Code Flow (port-based)
|
||||
| | - Gemini, Codex, Antigravity, Kiro (port 9876)
|
||||
| | - Opens browser for user auth
|
||||
| | - Callback to localhost:PORT
|
||||
| |
|
||||
| +---> Device Code Flow (no port needed)
|
||||
| - GitHub Copilot (ghcp)
|
||||
| - User enters code at github.com/login/device
|
||||
| - Polls for token completion
|
||||
| |
|
||||
| v
|
||||
| +------------------+
|
||||
| | OAuth Server | Browser-based auth
|
||||
| +------------------+
|
||||
|
|
||||
+---> Request Transformation
|
||||
| |
|
||||
| v
|
||||
| Anthropic Format --> Provider Format
|
||||
|
|
||||
+---> Image Analysis Hook (v7.34)
|
||||
| |
|
||||
| v
|
||||
| Vision Model Proxying (gemini, codex, agy, cliproxy)
|
||||
| - Auto-injected via claude-hooks
|
||||
| - Skip for Claude Sub accounts (native vision)
|
||||
| - Fallback with deprecated block-image-read
|
||||
|
|
||||
+---> Provider APIs
|
||||
|
|
||||
+---> Google (Gemini)
|
||||
+---> GitHub (Codex)
|
||||
+---> Antigravity
|
||||
+---> AWS Kiro (Claude-powered)
|
||||
+---> GitHub Copilot (ghcp)
|
||||
+---> OpenAI-compatible endpoints
|
||||
```
|
||||
|
||||
### GLMT Proxy Flow
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| GLMT Proxy Integration |
|
||||
+===========================================================================+
|
||||
|
||||
Claude CLI
|
||||
|
|
||||
| ANTHROPIC_BASE_URL = localhost:XXXX
|
||||
v
|
||||
+------------------+
|
||||
| GLMT Proxy | Embedded Node.js proxy (src/glmt/)
|
||||
| (glmt-proxy.ts)|
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Delta Accumulator| Stream transformation
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Pipeline | Request/Response transformation
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| GLM API | Z.AI / Kimi API
|
||||
+------------------+
|
||||
```
|
||||
|
||||
### Remote CLIProxy Flow (v7.1)
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Remote CLIProxy Architecture |
|
||||
+===========================================================================+
|
||||
|
||||
Config Resolution (proxy-config-resolver.ts)
|
||||
|
|
||||
+---> Priority: CLI flags > ENV vars > config.yaml > defaults
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| ResolvedProxyConfig |
|
||||
| mode: local|remote |
|
||||
+------------------+
|
||||
|
|
||||
+---> [mode = local] ---> Spawn local CLIProxyAPI binary
|
||||
| |
|
||||
| v
|
||||
| localhost:8317
|
||||
|
|
||||
+---> [mode = remote] ---> Connect to remote server
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Health Check | remote-proxy-client.ts
|
||||
| /v1/models | 2s timeout
|
||||
+------------------+
|
||||
|
|
||||
+---> [reachable] ---> Use remote
|
||||
| |
|
||||
| v
|
||||
| protocol://host:port
|
||||
|
|
||||
+---> [unreachable] ---> Fallback decision
|
||||
|
|
||||
+-----------------------------+
|
||||
|
|
||||
+---> [fallbackEnabled] ---> Start local
|
||||
|
|
||||
+---> [remoteOnly] ---> Fail with error
|
||||
|
||||
CLI Flags:
|
||||
--proxy-host <host> Remote hostname/IP
|
||||
--proxy-port <port> Port (default: 8317 HTTP, 443 HTTPS)
|
||||
--proxy-protocol <proto> http or https
|
||||
--proxy-auth-token <token> Bearer authentication
|
||||
--local-proxy Force local mode
|
||||
--remote-only Fail if remote unreachable
|
||||
|
||||
Environment Variables:
|
||||
CCS_PROXY_HOST Remote hostname
|
||||
CCS_PROXY_PORT Remote port
|
||||
CCS_PROXY_PROTOCOL Protocol (http/https)
|
||||
CCS_PROXY_AUTH_TOKEN Auth token
|
||||
CCS_PROXY_FALLBACK_ENABLED Enable fallback (true/false)
|
||||
```
|
||||
|
||||
### Quota Management Flow (v7.14)
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Quota Management Architecture |
|
||||
+===========================================================================+
|
||||
|
||||
Pre-Flight Check (before session start)
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| quota-manager.ts | Hybrid quota management
|
||||
+------------------+
|
||||
|
|
||||
+---> Get all active accounts for provider
|
||||
|
|
||||
+---> For each account:
|
||||
| |
|
||||
| v
|
||||
| +------------------+
|
||||
| | quota-fetcher.ts | Provider-specific API calls
|
||||
| +------------------+
|
||||
| |
|
||||
| +---> Check isPaused flag --> Skip if paused
|
||||
| |
|
||||
| +---> Fetch quota from provider API
|
||||
| | - Gemini: /models endpoint
|
||||
| | - Codex: /api/v1/account
|
||||
| | - Kiro: /api/usage
|
||||
| |
|
||||
| +---> Detect tier (free/paid/unknown)
|
||||
| |
|
||||
| +---> Check exhaustion status
|
||||
|
|
||||
+---> Select best account (not paused, not exhausted)
|
||||
|
|
||||
+---> Auto-failover to next account if current exhausted
|
||||
|
||||
CLI Commands:
|
||||
ccs cliproxy pause <account> --> Set isPaused=true in account-manager
|
||||
ccs cliproxy resume <account> --> Set isPaused=false
|
||||
ccs cliproxy status [account] --> Display quota + tier info
|
||||
|
||||
Dashboard UI:
|
||||
- Pause/Resume toggle per account
|
||||
- Tier badge (free/paid/unknown)
|
||||
- Quota usage display
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Architecture
|
||||
|
||||
### Config File Hierarchy
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Configuration Hierarchy |
|
||||
+===========================================================================+
|
||||
|
||||
~/.ccs/
|
||||
|
|
||||
+---> config.yaml # Main CCS config (unified)
|
||||
|
|
||||
+---> profiles.json # Claude account registry
|
||||
|
|
||||
+---> <profile>.settings.json # Per-profile settings
|
||||
|
|
||||
+---> cliproxy/
|
||||
| |
|
||||
| +---> config.yaml # CLIProxy configuration
|
||||
| +---> auth/ # OAuth tokens
|
||||
| +---> bin/ # CLIProxy binary
|
||||
|
|
||||
+---> shared/ # Symlinked resources
|
||||
|
|
||||
+---> commands/ # Claude Code commands
|
||||
+---> skills/ # Custom skills
|
||||
+---> agents/ # Agent configurations
|
||||
```
|
||||
|
||||
### Config Loading Order
|
||||
|
||||
```
|
||||
1. Environment Variables (highest priority)
|
||||
|
|
||||
v
|
||||
2. CLI Arguments
|
||||
|
|
||||
v
|
||||
3. Profile-specific settings (~/.ccs/<profile>.settings.json)
|
||||
|
|
||||
v
|
||||
4. Main config (~/.ccs/config.yaml)
|
||||
|
|
||||
v
|
||||
5. Default values (lowest priority)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebSocket Architecture
|
||||
|
||||
### Real-time Communication
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| WebSocket Communication |
|
||||
+===========================================================================+
|
||||
|
||||
Dashboard (React) Server (Express)
|
||||
| |
|
||||
|<------ Connection Established ------>|
|
||||
| |
|
||||
|<------ health:update ----------------| Health status
|
||||
| |
|
||||
|<------ auth:status ------------------| Auth changes
|
||||
| |
|
||||
|<------ usage:update -----------------| Usage stats
|
||||
| |
|
||||
|------- action:refresh -------------->| User requests
|
||||
| |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Authentication Flow |
|
||||
+===========================================================================+
|
||||
|
||||
OAuth Providers - Authorization Code Flow (Gemini, Codex, AGY)
|
||||
--------------------------------------------------------------
|
||||
|
||||
1. User runs: ccs gemini
|
||||
|
|
||||
v
|
||||
2. Check token cache (~/.ccs/cliproxy/auth/)
|
||||
|
|
||||
+---> [Valid token] ---> Use cached token
|
||||
|
|
||||
+---> [No/Expired token]
|
||||
|
|
||||
v
|
||||
3. Open browser for OAuth (localhost:PORT callback)
|
||||
v
|
||||
4. Callback with auth code
|
||||
|
|
||||
v
|
||||
5. Exchange for access token
|
||||
|
|
||||
v
|
||||
6. Cache token locally
|
||||
|
||||
|
||||
Kiro OAuth - Method-Aware Flow (CLI + Dashboard parity)
|
||||
-------------------------------------------------------
|
||||
|
||||
Supported methods:
|
||||
- aws: Device Code (default, AWS org friendly)
|
||||
- aws-authcode: Authorization Code via CLI flow
|
||||
- google: Social OAuth via management API
|
||||
- github: Social OAuth via management API (Dashboard flow)
|
||||
|
||||
Key behavior:
|
||||
- Device Code method uses /start route (no callback port)
|
||||
- Callback/social methods use /start-url + status polling
|
||||
- Some management flows return state first, auth_url later
|
||||
|
||||
|
||||
OAuth Providers - Device Code Flow (GitHub Copilot/ghcp)
|
||||
--------------------------------------------------------
|
||||
|
||||
1. User runs: ccs ghcp
|
||||
|
|
||||
v
|
||||
2. Check token cache (~/.ccs/cliproxy/auth/)
|
||||
|
|
||||
+---> [Valid token] ---> Use cached token
|
||||
|
|
||||
+---> [No/Expired token]
|
||||
|
|
||||
v
|
||||
3. Request device code from GitHub
|
||||
|
|
||||
v
|
||||
4. Display user code + verification URL
|
||||
| "Enter code XXXX-XXXX at github.com/login/device"
|
||||
v
|
||||
5. Poll for token (user completes auth in browser)
|
||||
|
|
||||
v
|
||||
6. Receive and cache token locally
|
||||
|
||||
|
||||
API Key Profiles (GLM, Kimi)
|
||||
----------------------------
|
||||
|
||||
1. User configures API key in settings
|
||||
|
|
||||
v
|
||||
2. Key stored in ~/.ccs/<profile>.settings.json
|
||||
|
|
||||
v
|
||||
3. Key passed via ANTHROPIC_AUTH_TOKEN env var
|
||||
```
|
||||
|
||||
### Security Boundaries
|
||||
|
||||
```
|
||||
+------------------+
|
||||
| User Terminal |
|
||||
+------------------+
|
||||
|
|
||||
| Local only (no network exposure)
|
||||
v
|
||||
+------------------+
|
||||
| CCS CLI |
|
||||
+------------------+
|
||||
|
|
||||
| Localhost only (127.0.0.1)
|
||||
v
|
||||
+------------------+
|
||||
| CLIProxy/GLMT | Binds to localhost only
|
||||
+------------------+
|
||||
|
|
||||
| TLS encrypted
|
||||
v
|
||||
+------------------+
|
||||
| Provider APIs | External endpoints
|
||||
+------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build and Distribution
|
||||
|
||||
### Build Pipeline
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Build Pipeline |
|
||||
+===========================================================================+
|
||||
|
||||
src/ (TypeScript) ui/src/ (React TSX)
|
||||
| |
|
||||
v v
|
||||
TypeScript Compiler Vite Build
|
||||
| |
|
||||
v v
|
||||
dist/ (JavaScript) dist/ui/ (Static assets)
|
||||
| |
|
||||
+---------------+---------------------+
|
||||
|
|
||||
v
|
||||
npm package (@kaitranntt/ccs)
|
||||
|
|
||||
v
|
||||
npm registry / GitHub releases
|
||||
```
|
||||
|
||||
### Package Contents
|
||||
|
||||
```
|
||||
@kaitranntt/ccs
|
||||
|
|
||||
+---> dist/ # Compiled CLI
|
||||
+---> dist/ui/ # Built dashboard
|
||||
+---> lib/ # Native scripts
|
||||
| +---> ccs # Bash bootstrap
|
||||
| +---> ccs.ps1 # PowerShell bootstrap
|
||||
+---> package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment Architecture
|
||||
|
||||
### Local Installation
|
||||
|
||||
```
|
||||
npm install -g @kaitranntt/ccs
|
||||
|
|
||||
v
|
||||
Global node_modules
|
||||
|
|
||||
+---> Creates symlink: ccs --> dist/ccs.js
|
||||
|
|
||||
+---> First run creates: ~/.ccs/
|
||||
```
|
||||
|
||||
### Runtime Dependencies
|
||||
|
||||
```
|
||||
+------------------+ +------------------+
|
||||
| Node.js 14+ | | Claude CLI |
|
||||
| (required) | | (required) |
|
||||
+------------------+ +------------------+
|
||||
|
||||
+------------------+ +------------------+
|
||||
| CLIProxyAPI | | Gemini CLI |
|
||||
| (auto-managed) | | (optional) |
|
||||
+------------------+ +------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Codebase Summary](./codebase-summary.md) - Detailed directory structure
|
||||
- [Code Standards](./code-standards.md) - Coding conventions
|
||||
- [Project Roadmap](./project-roadmap.md) - Development phases
|
||||
- [WebSearch](./websearch.md) - WebSearch feature details
|
||||
@@ -0,0 +1,402 @@
|
||||
# CCS System Architecture
|
||||
|
||||
Last Updated: 2026-02-16
|
||||
|
||||
High-level architecture overview for the CCS (Claude Code Switch) system.
|
||||
|
||||
---
|
||||
|
||||
## System Overview
|
||||
|
||||
CCS is a CLI wrapper that enables seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, Kiro, GitHub Copilot, OpenRouter, Qwen, Kimi, DeepSeek). It now supports multiple CLI targets (Claude Code, Factory Droid) for credential delivery.
|
||||
|
||||
The system consists of two main components:
|
||||
|
||||
1. **CLI Application** (`src/`) - Node.js TypeScript CLI
|
||||
2. **Dashboard UI** (`ui/`) - React web application served by Express
|
||||
|
||||
CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy with automatic injection for all profile types.
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| CCS System |
|
||||
+===========================================================================+
|
||||
| |
|
||||
| +------------------+ +-----------------+ +----------------+ |
|
||||
| | User Terminal | ---> | CCS CLI | ---> | Target CLI | |
|
||||
| | (ccs command) | | (src/ccs.ts) | | (claude/droid) | |
|
||||
| +------------------+ +-----------------+ +----------------+ |
|
||||
| | | |
|
||||
| v v |
|
||||
| +------------------+ +-----------------+ +----------------+ |
|
||||
| | Dashboard UI | <--> | Express | ---> | Provider APIs | |
|
||||
| | (React SPA) | | Web Server | | (Claude/GLM/ | |
|
||||
| +------------------+ +-----------------+ | Gemini/etc) | |
|
||||
| | +----------------+ |
|
||||
| v |
|
||||
| +---------------------+ |
|
||||
| | CLIProxyAPI | |
|
||||
| | (Local or Remote) | |
|
||||
| +---------------------+ |
|
||||
| |
|
||||
+===========================================================================+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Architecture
|
||||
|
||||
### Multi-Target Adapter System
|
||||
|
||||
CCS v7.45 introduces the Target Adapter pattern, enabling seamless integration with different CLI implementations.
|
||||
|
||||
**Key architecture:**
|
||||
|
||||
```
|
||||
Profile Resolution (CLIProxy, GLMT, Account-based)
|
||||
|
|
||||
v
|
||||
Target Resolution (--target flag > config > argv[0] > default)
|
||||
|
|
||||
v
|
||||
Get Target Adapter (Claude or Droid)
|
||||
|
|
||||
+---> detectBinary() (find CLI on system)
|
||||
|
|
||||
+---> prepareCredentials() (write config or set env)
|
||||
|
|
||||
+---> buildArgs() (construct CLI arguments)
|
||||
|
|
||||
+---> buildEnv() (prepare environment variables)
|
||||
|
|
||||
v
|
||||
Spawn Target Process
|
||||
```
|
||||
|
||||
**Each target adapter implements different credential delivery:**
|
||||
|
||||
- **Claude Adapter**: Env var delivery (existing behavior)
|
||||
- `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`
|
||||
- No config files needed
|
||||
|
||||
- **Droid Adapter**: Config file delivery to `~/.factory/settings.json`
|
||||
- Writes custom model entry: `custom:ccs-<profile>`
|
||||
- Spawns: `droid -m custom:ccs-<profile> <args>`
|
||||
- Model config includes baseUrl, apiKey, provider
|
||||
|
||||
**Binary alias pattern (busybox-style):**
|
||||
|
||||
```
|
||||
ccs → Target: claude (default)
|
||||
ccsd → Target: droid (auto-selected via argv[0])
|
||||
```
|
||||
|
||||
For details on the adapter architecture, see [Target Adapters](./target-adapters.md).
|
||||
|
||||
### CLI Layer
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| CLI Architecture |
|
||||
+===========================================================================+
|
||||
|
||||
User Input (ccs [--target <cli>] <profile> [args])
|
||||
|
|
||||
v
|
||||
+-------------+
|
||||
| ccs.ts | Entry point, command routing
|
||||
+-------------+
|
||||
|
|
||||
+---> [Version/Help/Doctor/etc.] ---> Exit
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Target Resolution | Determine which CLI to use
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
+-------------+
|
||||
| Profile | Determines execution path
|
||||
| Detection |
|
||||
+-------------+
|
||||
|
|
||||
+---> [Native Claude Account] ---> execClaude()
|
||||
| |
|
||||
+---> [CLIProxy Provider] ---> execClaudeWithCLIProxy()
|
||||
| |
|
||||
+---> [GLMT Profile] ---> execClaudeWithProxy()
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Target Adapter | Get appropriate adapter
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Prepare Creds | Deliver credentials
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Target CLI | Claude Code or Droid
|
||||
+------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Architecture
|
||||
|
||||
### CLI Execution Flow
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| CLI Execution Flow |
|
||||
+===========================================================================+
|
||||
|
||||
1. Parse Arguments
|
||||
|
|
||||
v
|
||||
2. Resolve Target Type
|
||||
|
|
||||
v
|
||||
3. Detect Profile Type
|
||||
|
|
||||
+---> Native Claude ---> 3a. Load Account Settings
|
||||
| |
|
||||
| v
|
||||
| 4a. Set CLAUDE_CONFIG_DIR
|
||||
| |
|
||||
| v
|
||||
| 5a. Get Claude Target Adapter
|
||||
|
|
||||
+---> CLIProxy -------> 3b. Ensure Binary Installed
|
||||
| |
|
||||
| v
|
||||
| 4b. Generate Config
|
||||
| |
|
||||
| v
|
||||
| 5b. Resolve Target Adapter
|
||||
| |
|
||||
| v
|
||||
| 6b. Prepare Credentials
|
||||
| |
|
||||
| v
|
||||
| 7b. Spawn via Adapter
|
||||
|
|
||||
+---> GLMT -----------> 3c. Start Embedded Proxy
|
||||
|
|
||||
v
|
||||
4c. Resolve Target Adapter
|
||||
|
|
||||
v
|
||||
5c. Spawn via Adapter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Provider Integration Architecture
|
||||
|
||||
For detailed provider flows (CLIProxyAPI, GLMT, quota management), see [Provider Flows](./provider-flows.md).
|
||||
|
||||
---
|
||||
|
||||
## Configuration Architecture
|
||||
|
||||
### Config File Hierarchy
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Configuration Hierarchy |
|
||||
+===========================================================================+
|
||||
|
||||
~/.ccs/
|
||||
|
|
||||
+---> config.yaml # Main CCS config (unified)
|
||||
|
|
||||
+---> profiles.json # Claude account registry
|
||||
|
|
||||
+---> <profile>.settings.json # Per-profile settings
|
||||
|
|
||||
+---> cliproxy/
|
||||
| |
|
||||
| +---> config.yaml # CLIProxy configuration
|
||||
| +---> auth/ # OAuth tokens
|
||||
| +---> bin/ # CLIProxy binary
|
||||
|
|
||||
+---> shared/ # Symlinked resources
|
||||
|
|
||||
+---> commands/ # Claude Code commands
|
||||
+---> skills/ # Custom skills
|
||||
+---> agents/ # Agent configurations
|
||||
|
||||
~/.factory/ (Droid CLI)
|
||||
|
|
||||
+---> settings.json # Droid config (custom models)
|
||||
```
|
||||
|
||||
### Config Loading Order
|
||||
|
||||
```
|
||||
1. Environment Variables (highest priority)
|
||||
|
|
||||
v
|
||||
2. CLI Arguments (including --target)
|
||||
|
|
||||
v
|
||||
3. Profile-specific settings (~/.ccs/<profile>.settings.json)
|
||||
|
|
||||
v
|
||||
4. Main config (~/.ccs/config.yaml)
|
||||
|
|
||||
v
|
||||
5. Default values (lowest priority)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebSocket Architecture
|
||||
|
||||
### Real-time Communication
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| WebSocket Communication |
|
||||
+===========================================================================+
|
||||
|
||||
Dashboard (React) Server (Express)
|
||||
| |
|
||||
|<------ Connection Established ------>|
|
||||
| |
|
||||
|<------ health:update ----------------| Health status
|
||||
| |
|
||||
|<------ auth:status ------------------| Auth changes
|
||||
| |
|
||||
|<------ usage:update -----------------| Usage stats
|
||||
| |
|
||||
|------- action:refresh -------------->| User requests
|
||||
| |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
See [Provider Flows](./provider-flows.md) → Authentication Flow section.
|
||||
|
||||
### Security Boundaries
|
||||
|
||||
```
|
||||
+------------------+
|
||||
| User Terminal |
|
||||
+------------------+
|
||||
|
|
||||
| Local only (no network exposure)
|
||||
v
|
||||
+------------------+
|
||||
| CCS CLI |
|
||||
+------------------+
|
||||
|
|
||||
| Localhost only (127.0.0.1)
|
||||
v
|
||||
+------------------+
|
||||
| CLIProxy/GLMT | Binds to localhost only
|
||||
+------------------+
|
||||
|
|
||||
| TLS encrypted
|
||||
v
|
||||
+------------------+
|
||||
| Target CLI | Spawned locally (claude/droid)
|
||||
+------------------+
|
||||
|
|
||||
| TLS encrypted
|
||||
v
|
||||
+------------------+
|
||||
| Provider APIs | External endpoints
|
||||
+------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build and Distribution
|
||||
|
||||
### Build Pipeline
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Build Pipeline |
|
||||
+===========================================================================+
|
||||
|
||||
src/ (TypeScript) ui/src/ (React TSX)
|
||||
| |
|
||||
v v
|
||||
TypeScript Compiler Vite Build
|
||||
| |
|
||||
v v
|
||||
dist/ (JavaScript) dist/ui/ (Static assets)
|
||||
| |
|
||||
+---------------+---------------------+
|
||||
|
|
||||
v
|
||||
npm package (@kaitranntt/ccs)
|
||||
|
|
||||
v
|
||||
npm registry / GitHub releases
|
||||
```
|
||||
|
||||
### Package Contents
|
||||
|
||||
```
|
||||
@kaitranntt/ccs
|
||||
|
|
||||
+---> dist/ # Compiled CLI
|
||||
+---> dist/ui/ # Built dashboard
|
||||
+---> lib/ # Native scripts
|
||||
| +---> ccs # Bash bootstrap
|
||||
| +---> ccs.ps1 # PowerShell bootstrap
|
||||
+---> package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment Architecture
|
||||
|
||||
### Local Installation
|
||||
|
||||
```
|
||||
npm install -g @kaitranntt/ccs
|
||||
|
|
||||
v
|
||||
Global node_modules
|
||||
|
|
||||
+---> Creates symlink: ccs --> dist/ccs.js
|
||||
|
|
||||
+---> Binary alias: ccsd → ccs (auto-selects droid target)
|
||||
|
|
||||
+---> First run creates: ~/.ccs/
|
||||
```
|
||||
|
||||
### Runtime Dependencies
|
||||
|
||||
```
|
||||
+------------------+ +------------------+
|
||||
| Node.js 14+ | | Claude CLI |
|
||||
| (required) | | (required) |
|
||||
+------------------+ +------------------+
|
||||
|
||||
+------------------+ +------------------+
|
||||
| CLIProxyAPI | | Droid CLI |
|
||||
| (auto-managed) | | (optional) |
|
||||
+------------------+ +------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Codebase Summary](../codebase-summary.md) - Detailed directory structure
|
||||
- [Code Standards](../code-standards.md) - Coding conventions & patterns
|
||||
- [Target Adapters](./target-adapters.md) - Multi-CLI adapter architecture
|
||||
- [Provider Flows](./provider-flows.md) - CLIProxy, GLMT, authentication flows
|
||||
- [Project Roadmap](../project-roadmap.md) - Development phases
|
||||
@@ -0,0 +1,565 @@
|
||||
# Provider Integration Flows
|
||||
|
||||
Last Updated: 2026-02-16
|
||||
|
||||
Detailed provider integration flows including CLIProxyAPI, GLMT proxy, remote CLIProxy, quota management, and authentication.
|
||||
|
||||
---
|
||||
|
||||
## CLIProxyAPI Flow
|
||||
|
||||
### Overview
|
||||
|
||||
CLIProxyAPI is a local OAuth proxy binary that enables seamless integration with multiple AI providers. CCS manages the binary and configuration automatically.
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| CLIProxyAPI Integration |
|
||||
+===========================================================================+
|
||||
|
||||
Claude CLI
|
||||
|
|
||||
| ANTHROPIC_BASE_URL = localhost:XXXX
|
||||
v
|
||||
+------------------+
|
||||
| CLIProxyAPI | Local proxy binary (CLIProxyAPIPlus for kiro/ghcp)
|
||||
| (binary) |
|
||||
+------------------+
|
||||
|
|
||||
+---> OAuth Authentication
|
||||
| |
|
||||
| +---> Authorization Code Flow (port-based)
|
||||
| | - Gemini, Codex, Antigravity, Kiro (port 9876)
|
||||
| | - Opens browser for user auth
|
||||
| | - Callback to localhost:PORT
|
||||
| |
|
||||
| +---> Device Code Flow (no port needed)
|
||||
| - GitHub Copilot (ghcp)
|
||||
| - User enters code at github.com/login/device
|
||||
| - Polls for token completion
|
||||
| |
|
||||
| v
|
||||
| +------------------+
|
||||
| | OAuth Server | Browser-based auth
|
||||
| +------------------+
|
||||
|
|
||||
+---> Request Transformation
|
||||
| |
|
||||
| v
|
||||
| Anthropic Format --> Provider Format
|
||||
|
|
||||
+---> Image Analysis Hook (v7.34)
|
||||
| |
|
||||
| v
|
||||
| Vision Model Proxying (gemini, codex, agy, clipproxy)
|
||||
| - Auto-injected via claude-hooks
|
||||
| - Skip for Claude Sub accounts (native vision)
|
||||
| - Fallback with deprecated block-image-read
|
||||
|
|
||||
+---> Provider APIs
|
||||
|
|
||||
+---> Google (Gemini)
|
||||
+---> GitHub (Codex)
|
||||
+---> Antigravity (AGY)
|
||||
+---> AWS Kiro (Claude-powered)
|
||||
+---> GitHub Copilot (ghcp)
|
||||
+---> OpenAI-compatible endpoints
|
||||
```
|
||||
|
||||
### Supported Hardcoded Providers
|
||||
|
||||
| Provider | ID | Auth Method | Port | Binary |
|
||||
|----------|----|----|------|--------|
|
||||
| Gemini | `gemini` | Authorization Code | 9876 | CLIProxyAPI |
|
||||
| Codex | `codex` | Authorization Code | 9876 | CLIProxyAPI |
|
||||
| Antigravity | `agy` | Authorization Code | 9876 | CLIProxyAPI |
|
||||
| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | 9876 | CLIProxyAPIPlus |
|
||||
| GitHub Copilot | `ghcp` | Device Code | none | CLIProxyAPIPlus |
|
||||
|
||||
### Hardcoded Provider Detection
|
||||
|
||||
CCS detects hardcoded providers via `profile-detector.ts` and routes through `execClaudeWithCLIProxy()`.
|
||||
|
||||
```typescript
|
||||
// Profile name matching
|
||||
const hardcodedProviders = ['gemini', 'codex', 'agy', 'kiro', 'ghcp'];
|
||||
|
||||
if (hardcodedProviders.includes(profileName)) {
|
||||
return execClaudeWithCLIProxy(claudeCli, profileName, args);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GLMT Proxy Flow
|
||||
|
||||
### Overview
|
||||
|
||||
GLMT proxy enables seamless integration with GLM-compatible APIs (Z.AI, Kimi, OpenRouter, etc.) using a Node.js-based embedded proxy.
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| GLMT Proxy Integration |
|
||||
+===========================================================================+
|
||||
|
||||
Claude CLI
|
||||
|
|
||||
| ANTHROPIC_BASE_URL = localhost:XXXX
|
||||
v
|
||||
+------------------+
|
||||
| GLMT Proxy | Embedded Node.js proxy (src/glmt/)
|
||||
| (glmt-proxy.ts)|
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Delta Accumulator| Stream transformation
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Pipeline | Request/Response transformation
|
||||
+------------------+
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| GLM API | Z.AI / Kimi API
|
||||
+------------------+
|
||||
```
|
||||
|
||||
### Supported GLM Providers
|
||||
|
||||
| Provider | Config Key | Endpoint | Auth |
|
||||
|----------|------------|----------|------|
|
||||
| Z.AI (GLM) | `glmt` | https://open.bigmodel.cn/api/paas/v4/ | API key |
|
||||
| Kimi | `kimi` | https://api.moonshot.cn/v1/ | API key |
|
||||
| OpenRouter | `openrouter` | https://openrouter.ai/api/v1/ | API key |
|
||||
|
||||
### GLMT Profile Detection
|
||||
|
||||
CCS detects GLMT profiles and routes through `execClaudeWithProxy()`:
|
||||
|
||||
```typescript
|
||||
// Settings-based profile detection
|
||||
const settings = loadSettings(profileName);
|
||||
if (settings.env?.ANTHROPIC_BASE_URL?.includes('glm') ||
|
||||
settings.env?.ANTHROPIC_BASE_URL?.includes('moonshot') ||
|
||||
settings.env?.ANTHROPIC_BASE_URL?.includes('openrouter')) {
|
||||
return execClaudeWithProxy(claudeCli, profileName, args);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Remote CLIProxy Flow (v7.1)
|
||||
|
||||
### Overview
|
||||
|
||||
Remote CLIProxy enables CCS to delegate authentication to a central proxy server instead of spawning a local binary.
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Remote CLIProxy Architecture (v7.1) |
|
||||
+===========================================================================+
|
||||
|
||||
Config Resolution (proxy-config-resolver.ts)
|
||||
|
|
||||
+---> Priority: CLI flags > ENV vars > config.yaml > defaults
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| ResolvedProxyConfig |
|
||||
| mode: local|remote |
|
||||
+------------------+
|
||||
|
|
||||
+---> [mode = local] ---> Spawn local CLIProxyAPI binary
|
||||
| |
|
||||
| v
|
||||
| localhost:8317
|
||||
|
|
||||
+---> [mode = remote] ---> Connect to remote server
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Health Check | remote-proxy-client.ts
|
||||
| /v1/models | 2s timeout
|
||||
+------------------+
|
||||
|
|
||||
+---> [reachable] ---> Use remote
|
||||
| |
|
||||
| v
|
||||
| protocol://host:port
|
||||
|
|
||||
+---> [unreachable] ---> Fallback decision
|
||||
|
|
||||
+-----------------------------+
|
||||
|
|
||||
+---> [fallbackEnabled] ---> Start local
|
||||
|
|
||||
+---> [remoteOnly] ---> Fail with error
|
||||
|
||||
CLI Flags:
|
||||
--proxy-host <host> Remote hostname/IP
|
||||
--proxy-port <port> Port (default: 8317 HTTP, 443 HTTPS)
|
||||
--proxy-protocol <proto> http or https
|
||||
--proxy-auth-token <token> Bearer authentication
|
||||
--local-proxy Force local mode
|
||||
--remote-only Fail if remote unreachable
|
||||
|
||||
Environment Variables:
|
||||
CCS_PROXY_HOST Remote hostname
|
||||
CCS_PROXY_PORT Remote port
|
||||
CCS_PROXY_PROTOCOL Protocol (http/https)
|
||||
CCS_PROXY_AUTH_TOKEN Auth token
|
||||
CCS_PROXY_FALLBACK_ENABLED Enable fallback (true/false)
|
||||
```
|
||||
|
||||
### Configuration Resolution
|
||||
|
||||
```typescript
|
||||
// proxy-config-resolver.ts: Priority order
|
||||
const resolved = {
|
||||
...DEFAULT_CONFIG, // 4. Defaults (lowest)
|
||||
...yamlConfig, // 3. config.yaml
|
||||
...envConfig, // 2. Environment variables
|
||||
...cliFlags, // 1. CLI flags (highest)
|
||||
};
|
||||
```
|
||||
|
||||
### Health Check
|
||||
|
||||
```typescript
|
||||
// remote-proxy-client.ts
|
||||
async function checkRemoteProxyHealth(config: ResolvedProxyConfig): Promise<boolean> {
|
||||
try {
|
||||
const url = `${config.protocol}://${config.host}:${config.port}/v1/models`;
|
||||
const response = await fetch(url, {
|
||||
headers: config.authToken ? { Authorization: `Bearer ${config.authToken}` } : {},
|
||||
timeout: 2000,
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quota Management Flow (v7.14)
|
||||
|
||||
### Overview
|
||||
|
||||
Hybrid quota management enables automatic detection of exhausted accounts and failover to next available account.
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Quota Management Architecture (v7.14) |
|
||||
+===========================================================================+
|
||||
|
||||
Pre-Flight Check (before session start)
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| quota-manager.ts | Hybrid quota management
|
||||
+------------------+
|
||||
|
|
||||
+---> Get all active accounts for provider
|
||||
|
|
||||
+---> For each account:
|
||||
| |
|
||||
| v
|
||||
| +------------------+
|
||||
| | quota-fetcher.ts | Provider-specific API calls
|
||||
| +------------------+
|
||||
| |
|
||||
| +---> Check isPaused flag --> Skip if paused
|
||||
| |
|
||||
| +---> Fetch quota from provider API
|
||||
| | - Gemini: /models endpoint
|
||||
| | - Codex: /api/v1/account
|
||||
| | - Kiro: /api/usage
|
||||
| |
|
||||
| +---> Detect tier (free/paid/unknown)
|
||||
| |
|
||||
| +---> Check exhaustion status
|
||||
|
|
||||
+---> Select best account (not paused, not exhausted)
|
||||
|
|
||||
+---> Auto-failover to next account if current exhausted
|
||||
|
||||
CLI Commands:
|
||||
ccs cliproxy pause <account> --> Set isPaused=true in account-manager
|
||||
ccs cliproxy resume <account> --> Set isPaused=false
|
||||
ccs cliproxy status [account] --> Display quota + tier info
|
||||
|
||||
Dashboard UI:
|
||||
- Pause/Resume toggle per account
|
||||
- Tier badge (free/paid/unknown)
|
||||
- Quota usage display
|
||||
```
|
||||
|
||||
### Account Selection Algorithm
|
||||
|
||||
```typescript
|
||||
// quota-manager.ts: Best account selection
|
||||
function selectBestAccount(accounts: AccountInfo[]): AccountInfo | null {
|
||||
// Priority:
|
||||
// 1. Not paused
|
||||
// 2. Not exhausted
|
||||
// 3. Paid tier over free tier
|
||||
// 4. Highest remaining quota
|
||||
|
||||
return accounts
|
||||
.filter(acc => !acc.isPaused && !acc.isExhausted)
|
||||
.sort((a, b) => {
|
||||
if (a.tier !== b.tier) return (a.tier === 'paid' ? -1 : 1);
|
||||
return (b.remainingQuota || 0) - (a.remainingQuota || 0);
|
||||
})[0] || null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
### OAuth Providers - Authorization Code Flow
|
||||
|
||||
**Providers**: Gemini, Codex, Antigravity, Kiro (aws method)
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| OAuth - Authorization Code Flow (Port-based) |
|
||||
+===========================================================================+
|
||||
|
||||
1. User runs: ccs gemini
|
||||
|
|
||||
v
|
||||
2. Check token cache (~/.ccs/cliproxy/auth/)
|
||||
|
|
||||
+---> [Valid token] ---> Use cached token
|
||||
|
|
||||
+---> [No/Expired token]
|
||||
|
|
||||
v
|
||||
3. Start local OAuth server (localhost:9876)
|
||||
|
|
||||
v
|
||||
4. Open browser with OAuth request
|
||||
| https://oauth-provider/authorize?redirect_uri=http://localhost:9876/callback
|
||||
v
|
||||
5. User authorizes in browser
|
||||
|
|
||||
v
|
||||
6. OAuth provider redirects to localhost:9876/callback?code=XXXX
|
||||
|
|
||||
v
|
||||
7. Exchange auth code for access token
|
||||
|
|
||||
v
|
||||
8. Cache token locally (~/.ccs/cliproxy/auth/gemini.json)
|
||||
|
|
||||
v
|
||||
9. Proceed with Claude CLI
|
||||
```
|
||||
|
||||
### OAuth Providers - Device Code Flow
|
||||
|
||||
**Providers**: GitHub Copilot (ghcp)
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| OAuth - Device Code Flow (No Port Needed) |
|
||||
+===========================================================================+
|
||||
|
||||
1. User runs: ccs ghcp
|
||||
|
|
||||
v
|
||||
2. Check token cache (~/.ccs/cliproxy/auth/)
|
||||
|
|
||||
+---> [Valid token] ---> Use cached token
|
||||
|
|
||||
+---> [No/Expired token]
|
||||
|
|
||||
v
|
||||
3. Request device code from GitHub
|
||||
|
|
||||
v
|
||||
4. Display user code + verification URL
|
||||
| "Enter code XXXX-XXXX at github.com/login/device"
|
||||
v
|
||||
5. User opens URL in browser and enters code
|
||||
|
|
||||
v
|
||||
6. Poll GitHub for token completion
|
||||
|
|
||||
v
|
||||
7. Receive and cache token locally
|
||||
|
|
||||
v
|
||||
8. Proceed with Claude CLI
|
||||
```
|
||||
|
||||
### Kiro OAuth - Method-Aware Flow
|
||||
|
||||
**Supported methods**:
|
||||
- `aws`: Device Code (default, AWS org friendly)
|
||||
- `aws-authcode`: Authorization Code via CLI flow
|
||||
- `google`: Social OAuth via management API
|
||||
- `github`: Social OAuth via management API (Dashboard flow)
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Kiro OAuth - Method-Aware Flow |
|
||||
+===========================================================================+
|
||||
|
||||
Configuration:
|
||||
ccs_profile:
|
||||
target: claude
|
||||
cliproxy:
|
||||
provider: kiro
|
||||
kiro_method: aws # or aws-authcode, google, github
|
||||
|
||||
Flow:
|
||||
Device Code (aws)
|
||||
→ /start endpoint (no callback port)
|
||||
→ Opens browser
|
||||
→ User enters code
|
||||
→ Poll /status
|
||||
|
||||
Authorization Code (aws-authcode, google, github)
|
||||
→ /start-url endpoint
|
||||
→ Returns auth_url
|
||||
→ User visits URL
|
||||
→ Callback handled
|
||||
→ Poll /status for completion
|
||||
|
||||
Key behavior:
|
||||
- Device Code method uses /start route (no callback port)
|
||||
- Callback/social methods use /start-url + status polling
|
||||
- Some management flows return state first, auth_url later
|
||||
```
|
||||
|
||||
### API Key Profiles (GLM, Kimi)
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| API Key Profile (Non-OAuth) |
|
||||
+===========================================================================+
|
||||
|
||||
1. User configures API key in settings
|
||||
|
|
||||
v
|
||||
2. Key stored in ~/.ccs/<profile>.settings.json
|
||||
|
|
||||
v
|
||||
3. Profile detection: APIKeyProfile
|
||||
|
|
||||
v
|
||||
4. Key passed via ANTHROPIC_AUTH_TOKEN env var
|
||||
|
|
||||
v
|
||||
5. Target adapter (Claude/Droid) handles delivery
|
||||
|
|
||||
└─ Claude: env var
|
||||
└─ Droid: config file (~/.factory/settings.json)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Image Analysis Hook Flow (v7.34)
|
||||
|
||||
### Overview
|
||||
|
||||
Image Analysis Hook enables vision model proxying through CLIProxy with automatic injection for all profile types.
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Image Analysis Hook Flow (v7.34) |
|
||||
+===========================================================================+
|
||||
|
||||
Claude CLI with image input
|
||||
|
|
||||
v
|
||||
Hook Installer (ensureProfileHooks)
|
||||
|
|
||||
+---> Check ~/.claude/hooks/openai-vision-hook.cjs exists
|
||||
|
|
||||
+---> If missing: auto-install via image-analyzer-hook-installer
|
||||
|
|
||||
v
|
||||
Hook Configuration
|
||||
|
|
||||
+---> Set ANTHROPIC_IMAGE_HOOK_URL
|
||||
| (proxy endpoint URL)
|
||||
|
|
||||
v
|
||||
Claude CLI processes image request
|
||||
|
|
||||
v
|
||||
Hook intercepts image request
|
||||
|
|
||||
v
|
||||
Vision Model Proxying (via CLIProxyAPI)
|
||||
|
|
||||
+---> Gemini, Codex, AGY support vision
|
||||
|
|
||||
+---> Kiro (Claude native vision)
|
||||
|
|
||||
+---> Skip for Claude Sub accounts (native vision)
|
||||
|
|
||||
v
|
||||
Vision response returned to Claude CLI
|
||||
```
|
||||
|
||||
### Hook Environment
|
||||
|
||||
```typescript
|
||||
// getImageAnalysisHookEnv()
|
||||
{
|
||||
ANTHROPIC_IMAGE_HOOK_URL: 'http://localhost:8317/api/image-analysis',
|
||||
// or for remote proxy:
|
||||
ANTHROPIC_IMAGE_HOOK_URL: 'https://proxy.example.com:8317/api/image-analysis',
|
||||
}
|
||||
```
|
||||
|
||||
### Provider Support
|
||||
|
||||
| Provider | Vision Support | Notes |
|
||||
|----------|---|---|
|
||||
| Gemini | ✓ | Via CLIProxy image analysis |
|
||||
| Codex | ✓ | Via CLIProxy image analysis |
|
||||
| Antigravity | ✓ | Via CLIProxy image analysis |
|
||||
| Kiro | ✓ | Native Claude vision (no proxy needed) |
|
||||
| Copilot | ✗ | Not supported |
|
||||
| GLM/Kimi | ✗ | Requires direct API implementation |
|
||||
|
||||
---
|
||||
|
||||
## Session Tracking
|
||||
|
||||
All execution paths record session metadata including target CLI used:
|
||||
|
||||
```typescript
|
||||
{
|
||||
profileName: 'gemini',
|
||||
profileType: 'clipproxy',
|
||||
provider: 'google-gemini',
|
||||
targetCli: 'claude', // NEW: which target was used
|
||||
timestamp: '2026-02-16T10:40:00Z',
|
||||
duration: 12345,
|
||||
exitCode: 0,
|
||||
model: 'claude-opus-4-6',
|
||||
}
|
||||
```
|
||||
|
||||
This enables analytics on target CLI usage and adoption.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [System Architecture Index](./index.md) — Overall system design
|
||||
- [Target Adapters](./target-adapters.md) — Multi-CLI adapter pattern
|
||||
- [Codebase Summary](../codebase-summary.md) — Module structure
|
||||
- [Code Standards](../code-standards.md) — Implementation guidelines
|
||||
@@ -0,0 +1,621 @@
|
||||
# Target Adapters
|
||||
|
||||
Last Updated: 2026-02-16
|
||||
|
||||
Detailed documentation of the target adapter pattern and implementations.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The target adapter system enables CCS to dispatch credential-resolved profiles to different CLI implementations while maintaining a unified configuration and profile system.
|
||||
|
||||
**Key insight**: Profile resolution (detecting provider, loading auth, building credentials) is target-agnostic. Only the final credential delivery and process spawning differ per target.
|
||||
|
||||
---
|
||||
|
||||
## Target Adapter Interface
|
||||
|
||||
Each CLI target implements the `TargetAdapter` contract:
|
||||
|
||||
```typescript
|
||||
export interface TargetAdapter {
|
||||
readonly type: TargetType; // 'claude' | 'droid'
|
||||
readonly displayName: string; // "Claude Code" | "Factory Droid"
|
||||
|
||||
/** Detect if the target CLI binary exists on system */
|
||||
detectBinary(): TargetBinaryInfo | null;
|
||||
|
||||
/** Prepare credentials for delivery to target CLI */
|
||||
prepareCredentials(creds: TargetCredentials): Promise<void>;
|
||||
|
||||
/** Build spawn arguments for the target CLI */
|
||||
buildArgs(profile: string, userArgs: string[]): string[];
|
||||
|
||||
/** Build environment variables for the target CLI */
|
||||
buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv;
|
||||
|
||||
/** Spawn the target CLI process (replaces current process flow) */
|
||||
exec(args: string[], env: NodeJS.ProcessEnv, options?: { cwd?: string }): void;
|
||||
|
||||
/** Check if a profile type is supported by this target */
|
||||
supportsProfileType(profileType: string): boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Type Definitions
|
||||
|
||||
```typescript
|
||||
export type TargetType = 'claude' | 'droid';
|
||||
|
||||
export interface TargetCredentials {
|
||||
baseUrl: string; // API endpoint
|
||||
apiKey: string; // Auth token
|
||||
model?: string; // Model ID
|
||||
provider?: 'anthropic' | 'openai' | 'generic-chat-completion-api';
|
||||
envVars?: NodeJS.ProcessEnv; // Additional env vars
|
||||
}
|
||||
|
||||
export interface TargetBinaryInfo {
|
||||
path: string; // Full path to binary
|
||||
needsShell: boolean; // Windows .cmd/.bat/.ps1?
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Target Resolution
|
||||
|
||||
CCS resolves which adapter to use via priority-ordered checks:
|
||||
|
||||
### Resolution Priority
|
||||
|
||||
```
|
||||
1. --target flag (CLI argument) — highest priority
|
||||
└─ ccs --target droid glm
|
||||
|
||||
2. Per-profile config (from ~/.ccs/config.yaml or settings.json)
|
||||
└─ profiles:
|
||||
glm:
|
||||
target: droid
|
||||
|
||||
3. argv[0] detection (busybox pattern) — binary name mapping
|
||||
└─ ccsd (symlink/batch file) → droid
|
||||
└─ ccs (regular command) → default
|
||||
|
||||
4. Fallback: 'claude' — lowest priority
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
// src/targets/target-resolver.ts
|
||||
|
||||
export function resolveTargetType(
|
||||
args: string[],
|
||||
profileConfig?: { target?: TargetType }
|
||||
): TargetType {
|
||||
// 1. Parse --target flags (supports --target value and --target=value)
|
||||
// Repeated flags: last one wins.
|
||||
const parsed = parseTargetFlags(args);
|
||||
if (parsed.targetOverride) {
|
||||
return parsed.targetOverride;
|
||||
}
|
||||
|
||||
// 2. Check profile config
|
||||
if (profileConfig?.target) {
|
||||
return profileConfig.target;
|
||||
}
|
||||
|
||||
// 3. Check argv[0] (binary name)
|
||||
const binName = path.basename(process.argv[1] || process.argv0 || '').replace(/\.(cmd|bat|ps1|exe)$/i, '');
|
||||
if (ARGV0_TARGET_MAP[binName]) {
|
||||
return ARGV0_TARGET_MAP[binName];
|
||||
}
|
||||
|
||||
// 4. Default to claude
|
||||
return 'claude';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Claude Adapter
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
// src/targets/claude-adapter.ts
|
||||
|
||||
export class ClaudeAdapter implements TargetAdapter {
|
||||
readonly type: TargetType = 'claude';
|
||||
readonly displayName = 'Claude Code';
|
||||
|
||||
detectBinary(): TargetBinaryInfo | null {
|
||||
const info = getClaudeCliInfo();
|
||||
if (!info) return null;
|
||||
return { path: info.path, needsShell: info.needsShell };
|
||||
}
|
||||
|
||||
async prepareCredentials(_creds: TargetCredentials): Promise<void> {
|
||||
// No-op: Claude receives credentials via environment variables
|
||||
}
|
||||
|
||||
buildArgs(_profile: string, userArgs: string[]): string[] {
|
||||
return userArgs; // Pass through user arguments unchanged
|
||||
}
|
||||
|
||||
buildEnv(creds: TargetCredentials, profileType: string): NodeJS.ProcessEnv {
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
|
||||
// For native profiles, strip stale proxy env to prevent interference
|
||||
const baseEnv =
|
||||
profileType === 'account' || profileType === 'default'
|
||||
? stripAnthropicEnv(process.env)
|
||||
: process.env;
|
||||
|
||||
const env: NodeJS.ProcessEnv = { ...baseEnv, ...webSearchEnv };
|
||||
|
||||
if (creds.envVars) {
|
||||
Object.assign(env, creds.envVars);
|
||||
}
|
||||
|
||||
// Deliver credentials via environment variables
|
||||
if (creds.baseUrl) env['ANTHROPIC_BASE_URL'] = creds.baseUrl;
|
||||
if (creds.apiKey) env['ANTHROPIC_AUTH_TOKEN'] = creds.apiKey;
|
||||
if (creds.model) env['ANTHROPIC_MODEL'] = creds.model;
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
exec(args: string[], env: NodeJS.ProcessEnv, _options?: { cwd?: string }): void {
|
||||
const claudeCli = detectClaudeCli();
|
||||
if (!claudeCli) {
|
||||
void ErrorManager.showClaudeNotFound();
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Windows shell requirements
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||
|
||||
let child: ChildProcess;
|
||||
if (needsShell) {
|
||||
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
|
||||
child = spawn(cmdString, { shell: true, stdio: 'inherit', env });
|
||||
} else {
|
||||
child = spawn(claudeCli, args, { stdio: 'inherit', env });
|
||||
}
|
||||
|
||||
// Handle process termination
|
||||
const onSigInt = () => child.kill('SIGINT');
|
||||
const onSigTerm = () => child.kill('SIGTERM');
|
||||
process.once('SIGINT', onSigInt);
|
||||
process.once('SIGTERM', onSigTerm);
|
||||
child.on('exit', () => {
|
||||
process.removeListener('SIGINT', onSigInt);
|
||||
process.removeListener('SIGTERM', onSigTerm);
|
||||
});
|
||||
}
|
||||
|
||||
supportsProfileType(profileType: string): boolean {
|
||||
// Claude supports all profile types
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Credential Delivery
|
||||
|
||||
**Method**: Environment variables
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL=https://api.anthropic.com
|
||||
export ANTHROPIC_AUTH_TOKEN=sk-ant-...
|
||||
export ANTHROPIC_MODEL=claude-opus-4-6
|
||||
export WEBSEARCH_HOOK_ENV=... # Image analysis, websearch
|
||||
```
|
||||
|
||||
### Execution
|
||||
|
||||
```bash
|
||||
# Direct invocation
|
||||
ccs gemini
|
||||
→ claude "args..."
|
||||
with ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN set
|
||||
|
||||
# With --target override
|
||||
ccs --target claude glm
|
||||
→ claude "args..."
|
||||
with ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN set
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Droid Adapter
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
// src/targets/droid-adapter.ts
|
||||
|
||||
export class DroidAdapter implements TargetAdapter {
|
||||
readonly type: TargetType = 'droid';
|
||||
readonly displayName = 'Factory Droid';
|
||||
|
||||
detectBinary(): TargetBinaryInfo | null {
|
||||
const info = getDroidBinaryInfo();
|
||||
if (!info) return null;
|
||||
|
||||
// Non-blocking version compatibility check
|
||||
checkDroidVersion(info.path);
|
||||
return info;
|
||||
}
|
||||
|
||||
async prepareCredentials(creds: TargetCredentials): Promise<void> {
|
||||
// Write custom model entry to ~/.factory/settings.json
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
buildArgs(profile: string, userArgs: string[]): string[] {
|
||||
// Droid uses -m <model> syntax for model selection
|
||||
return ['-m', `custom:ccs-${profile}`, ...userArgs];
|
||||
}
|
||||
|
||||
buildEnv(_creds: TargetCredentials, _profileType: string): NodeJS.ProcessEnv {
|
||||
// Droid reads from config file — minimal env needed
|
||||
return { ...process.env };
|
||||
}
|
||||
|
||||
exec(args: string[], env: NodeJS.ProcessEnv, _options?: { cwd?: string }): void {
|
||||
const droidPath = detectDroidCli();
|
||||
if (!droidPath) {
|
||||
console.error('[X] Droid CLI not found. Install: npm i -g @factory/cli');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Windows shell requirements
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(droidPath);
|
||||
|
||||
let child: ChildProcess;
|
||||
if (needsShell) {
|
||||
const cmdString = [droidPath, ...args].map(escapeShellArg).join(' ');
|
||||
child = spawn(cmdString, { shell: true, stdio: 'inherit', env });
|
||||
} else {
|
||||
child = spawn(droidPath, args, { stdio: 'inherit', env });
|
||||
}
|
||||
|
||||
// Handle process termination
|
||||
const onSigInt = () => child.kill('SIGINT');
|
||||
const onSigTerm = () => child.kill('SIGTERM');
|
||||
process.once('SIGINT', onSigInt);
|
||||
process.once('SIGTERM', onSigTerm);
|
||||
child.on('exit', () => {
|
||||
process.removeListener('SIGINT', onSigInt);
|
||||
process.removeListener('SIGTERM', onSigTerm);
|
||||
});
|
||||
}
|
||||
|
||||
supportsProfileType(profileType: string): boolean {
|
||||
// Droid currently supports direct settings/default paths only
|
||||
return profileType === 'settings' || profileType === 'default';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Credential Delivery
|
||||
|
||||
**Method**: Config file (`~/.factory/settings.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"customModels": [
|
||||
{
|
||||
"model": "claude-opus-4-6",
|
||||
"displayName": "CCS gemini",
|
||||
"baseUrl": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
"apiKey": "AIza...",
|
||||
"provider": "openai"
|
||||
},
|
||||
{
|
||||
"model": "glm-4",
|
||||
"displayName": "CCS glm",
|
||||
"baseUrl": "https://open.bigmodel.cn/api/paas/v4/",
|
||||
"apiKey": "your-glm-key",
|
||||
"provider": "openai"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Execution
|
||||
|
||||
```bash
|
||||
# Direct invocation
|
||||
ccs gemini
|
||||
→ droid -m custom:ccs-gemini "args..."
|
||||
(credentials loaded from ~/.factory/settings.json)
|
||||
|
||||
# With --target override
|
||||
ccs --target droid glm
|
||||
→ droid -m custom:ccs-glm "args..."
|
||||
(credentials loaded from ~/.factory/settings.json)
|
||||
```
|
||||
|
||||
### Binary Alias Pattern
|
||||
|
||||
```bash
|
||||
# Create alias/symlink to auto-select droid target
|
||||
ln -s /path/to/ccs /path/to/ccsd
|
||||
|
||||
# Usage
|
||||
ccsd glm
|
||||
→ Target: droid (detected from argv[0])
|
||||
→ droid -m custom:ccs-glm "args..."
|
||||
```
|
||||
|
||||
On Windows, `ccsd.cmd`, `ccsd.bat`, `ccsd.ps1`, and `ccsd.exe` wrappers are also recognized.
|
||||
|
||||
---
|
||||
|
||||
## Registry and Lookup
|
||||
|
||||
The target registry is a simple map-based store for adapters:
|
||||
|
||||
```typescript
|
||||
// src/targets/target-registry.ts
|
||||
|
||||
const adapters = new Map<TargetType, TargetAdapter>();
|
||||
|
||||
export function registerTarget(adapter: TargetAdapter): void {
|
||||
adapters.set(adapter.type, adapter);
|
||||
}
|
||||
|
||||
export function getTarget(type: TargetType): TargetAdapter {
|
||||
const adapter = adapters.get(type);
|
||||
if (!adapter) {
|
||||
throw new Error(`Unknown target "${type}"`);
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
export function getDefaultTarget(): TargetAdapter {
|
||||
return getTarget('claude');
|
||||
}
|
||||
```
|
||||
|
||||
### Adapter Registration
|
||||
|
||||
At startup, adapters self-register:
|
||||
|
||||
```typescript
|
||||
// src/ccs.ts (initialization)
|
||||
|
||||
registerTarget(new ClaudeAdapter());
|
||||
registerTarget(new DroidAdapter());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Execution Flow
|
||||
|
||||
### Step-by-Step
|
||||
|
||||
```
|
||||
1. Parse command-line arguments
|
||||
└─ args: ['--target', 'droid', 'glm']
|
||||
|
||||
2. Resolve target type
|
||||
└─ resolveTargetType(args) → 'droid'
|
||||
└─ stripTargetFlag(args) → ['glm']
|
||||
|
||||
3. Detect and resolve profile
|
||||
└─ detectProfile(['glm']) → { profile: 'glm', ... }
|
||||
└─ Load credentials from config/CLIProxy/env
|
||||
|
||||
4. Build credentials object
|
||||
└─ TargetCredentials {
|
||||
baseUrl: '...',
|
||||
apiKey: '...',
|
||||
model: 'claude-opus-4-6',
|
||||
envVars: { CCS_PROFILE_NAME: 'glm', ... }
|
||||
}
|
||||
|
||||
5. Get target adapter
|
||||
└─ getTarget('droid') → DroidAdapter instance
|
||||
|
||||
6. Prepare credentials
|
||||
└─ adapter.prepareCredentials(creds)
|
||||
└─ DroidAdapter: writes to ~/.factory/settings.json
|
||||
|
||||
7. Build spawn arguments
|
||||
└─ adapter.buildArgs('glm', []) → ['-m', 'custom:ccs-glm']
|
||||
|
||||
8. Build environment
|
||||
└─ adapter.buildEnv(creds, profileType) → process.env
|
||||
|
||||
9. Spawn target CLI
|
||||
└─ adapter.exec(spawnArgs, env)
|
||||
└─ exec spawn('droid', ['-m', 'custom:ccs-glm', ...])
|
||||
|
||||
10. Replace current process
|
||||
└─ Child process inherits stdio
|
||||
└─ Signal handlers propagate to child
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Target
|
||||
|
||||
To support a new CLI (e.g., MyAI CLI), follow this pattern:
|
||||
|
||||
### 1. Create Adapter Class
|
||||
|
||||
```typescript
|
||||
// src/targets/myai-adapter.ts
|
||||
|
||||
export class MyAiAdapter implements TargetAdapter {
|
||||
readonly type: TargetType = 'myai';
|
||||
readonly displayName = 'MyAI CLI';
|
||||
|
||||
detectBinary(): TargetBinaryInfo | null {
|
||||
const path = which.sync('myai', { nothrow: true });
|
||||
if (!path) return null;
|
||||
return { path, needsShell: process.platform === 'win32' };
|
||||
}
|
||||
|
||||
async prepareCredentials(creds: TargetCredentials): Promise<void> {
|
||||
// Write to ~/.myai/config or similar
|
||||
}
|
||||
|
||||
buildArgs(profile: string, userArgs: string[]): string[] {
|
||||
return ['-p', profile, ...userArgs];
|
||||
}
|
||||
|
||||
buildEnv(creds: TargetCredentials, _profileType: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
MYAI_API_KEY: creds.apiKey,
|
||||
MYAI_API_URL: creds.baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
exec(args: string[], env: NodeJS.ProcessEnv): void {
|
||||
const myaiPath = this.detectBinary()?.path;
|
||||
if (!myaiPath) {
|
||||
console.error('[X] MyAI CLI not found');
|
||||
process.exit(1);
|
||||
}
|
||||
spawn(myaiPath, args, { stdio: 'inherit', env });
|
||||
}
|
||||
|
||||
supportsProfileType(profileType: string): boolean {
|
||||
return true; // or implement specific logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Update Type Definition
|
||||
|
||||
```typescript
|
||||
// src/targets/target-adapter.ts
|
||||
|
||||
export type TargetType = 'claude' | 'droid' | 'myai';
|
||||
```
|
||||
|
||||
### 3. Register in ccs.ts
|
||||
|
||||
```typescript
|
||||
registerTarget(new MyAiAdapter());
|
||||
```
|
||||
|
||||
### 4. Update Documentation
|
||||
|
||||
- Add to [Codebase Summary](../codebase-summary.md)
|
||||
- Update Code Standards adapter examples
|
||||
- Document CLI-specific behavior
|
||||
|
||||
---
|
||||
|
||||
## Cross-Platform Considerations
|
||||
|
||||
### Windows Shell Detection
|
||||
|
||||
Both adapters check for shell-requiring binaries:
|
||||
|
||||
```typescript
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(binaryPath);
|
||||
|
||||
if (needsShell) {
|
||||
const cmdString = [binaryPath, ...args].map(escapeShellArg).join(' ');
|
||||
spawn(cmdString, { shell: true, stdio: 'inherit' });
|
||||
} else {
|
||||
spawn(binaryPath, args, { stdio: 'inherit' });
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variable Escaping
|
||||
|
||||
Arguments passed to shell are escaped to prevent injection:
|
||||
|
||||
```typescript
|
||||
export function escapeShellArg(arg: string): string {
|
||||
// Wrap in quotes and escape internal quotes
|
||||
return `"${arg.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
```
|
||||
|
||||
### Signal Handling
|
||||
|
||||
Both adapters propagate signals from parent to child:
|
||||
|
||||
```typescript
|
||||
const onSigInt = () => child.kill('SIGINT');
|
||||
const onSigTerm = () => child.kill('SIGTERM');
|
||||
process.once('SIGINT', onSigInt);
|
||||
process.once('SIGTERM', onSigTerm);
|
||||
|
||||
child.on('exit', () => {
|
||||
process.removeListener('SIGINT', onSigInt);
|
||||
process.removeListener('SIGTERM', onSigTerm);
|
||||
});
|
||||
```
|
||||
|
||||
This ensures CTRL+C and graceful shutdowns work correctly.
|
||||
|
||||
---
|
||||
|
||||
## Testing Target Adapters
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```typescript
|
||||
describe('ClaudeAdapter', () => {
|
||||
it('detects Claude CLI', () => {
|
||||
const adapter = new ClaudeAdapter();
|
||||
const binary = adapter.detectBinary();
|
||||
expect(binary).not.toBeNull();
|
||||
});
|
||||
|
||||
it('builds env with credentials', () => {
|
||||
const adapter = new ClaudeAdapter();
|
||||
const env = adapter.buildEnv({
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
apiKey: 'sk-ant-...',
|
||||
model: 'claude-opus-4-6',
|
||||
}, 'cliproxy');
|
||||
|
||||
expect(env['ANTHROPIC_AUTH_TOKEN']).toBe('sk-ant-...');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```bash
|
||||
# Test Claude adapter
|
||||
ccs --target claude help
|
||||
|
||||
# Test Droid adapter (if installed)
|
||||
ccs --target droid help
|
||||
|
||||
# Test argv[0] detection
|
||||
ccsd help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Codebase Summary](../codebase-summary.md) — Module structure
|
||||
- [Code Standards](../code-standards.md) — Adapter pattern guidelines
|
||||
- [System Architecture Index](./index.md) — Overall system design
|
||||
+4
-1
@@ -26,7 +26,8 @@
|
||||
"main": "dist/ccs.js",
|
||||
"types": "dist/ccs.d.ts",
|
||||
"bin": {
|
||||
"ccs": "dist/ccs.js"
|
||||
"ccs": "dist/ccs.js",
|
||||
"ccsd": "dist/ccs.js"
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
@@ -102,6 +103,7 @@
|
||||
"listr2": "^3.14.0",
|
||||
"open": "^8.4.2",
|
||||
"ora": "^5.4.1",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"ws": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -121,6 +123,7 @@
|
||||
"@types/express-session": "^1.18.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.19.25",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
|
||||
@@ -24,8 +24,9 @@ import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loade
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
import type { CLIProxyProvider } from '../cliproxy/types';
|
||||
import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities';
|
||||
|
||||
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default';
|
||||
import type { TargetType } from '../targets/target-adapter';
|
||||
import type { ProfileType } from '../types/profile';
|
||||
export type { ProfileType } from '../types/profile';
|
||||
|
||||
/** CLIProxy profile names (OAuth-based, zero config) */
|
||||
export const CLIPROXY_PROFILES: readonly CLIProxyProvider[] = CLIPROXY_PROVIDER_IDS;
|
||||
@@ -34,6 +35,7 @@ export type CLIProxyProfileName = CLIProxyProvider;
|
||||
export interface ProfileDetectionResult {
|
||||
type: ProfileType;
|
||||
name: string;
|
||||
target?: TargetType;
|
||||
settingsPath?: string;
|
||||
profile?: Settings | ProfileMetadata;
|
||||
message?: string;
|
||||
@@ -143,6 +145,7 @@ class ProfileDetector {
|
||||
return {
|
||||
type: 'cliproxy',
|
||||
name: profileName,
|
||||
target: composite.target,
|
||||
provider: defaultTierConfig.provider as CLIProxyProfileName,
|
||||
settingsPath: composite.settings,
|
||||
port: composite.port,
|
||||
@@ -156,6 +159,7 @@ class ProfileDetector {
|
||||
return {
|
||||
type: 'cliproxy',
|
||||
name: profileName,
|
||||
target: singleVariant.target,
|
||||
provider: singleVariant.provider as CLIProxyProfileName,
|
||||
settingsPath: singleVariant.settings,
|
||||
port: singleVariant.port,
|
||||
@@ -170,6 +174,7 @@ class ProfileDetector {
|
||||
return {
|
||||
type: 'settings',
|
||||
name: profileName,
|
||||
target: profile.target,
|
||||
env: settingsEnv,
|
||||
};
|
||||
}
|
||||
|
||||
+261
-54
@@ -38,6 +38,17 @@ import { handleUpdateCommand } from './commands/update-command';
|
||||
// Import extracted utility functions
|
||||
import { execClaude, escapeShellArg } from './utils/shell-executor';
|
||||
|
||||
// Import target adapter system
|
||||
import {
|
||||
registerTarget,
|
||||
getTarget,
|
||||
ClaudeAdapter,
|
||||
DroidAdapter,
|
||||
pruneOrphanedModels,
|
||||
type TargetCredentials,
|
||||
} from './targets';
|
||||
import { resolveTargetType, stripTargetFlag } from './targets/target-resolver';
|
||||
|
||||
// Version and Update check utilities
|
||||
import { getVersion } from './utils/version';
|
||||
import {
|
||||
@@ -170,7 +181,8 @@ async function execClaudeWithProxy(
|
||||
};
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||
const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli);
|
||||
const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli);
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
const imageAnalysisEnv = getImageAnalysisHookEnv(profileName);
|
||||
const env = {
|
||||
@@ -182,7 +194,17 @@ async function execClaudeWithProxy(
|
||||
};
|
||||
|
||||
let claude: ChildProcess;
|
||||
if (needsShell) {
|
||||
if (isPowerShellScript) {
|
||||
claude = spawn(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env,
|
||||
}
|
||||
);
|
||||
} else if (needsShell) {
|
||||
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
|
||||
claude = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
@@ -199,28 +221,57 @@ async function execClaudeWithProxy(
|
||||
}
|
||||
|
||||
// 5. Cleanup: kill proxy when Claude exits
|
||||
const forwardSigTerm = () => {
|
||||
proxy.kill('SIGTERM');
|
||||
claude.kill('SIGTERM');
|
||||
};
|
||||
const forwardSigInt = () => {
|
||||
proxy.kill('SIGTERM');
|
||||
claude.kill('SIGINT');
|
||||
};
|
||||
const forwardSighup = () => {
|
||||
proxy.kill('SIGTERM');
|
||||
claude.kill('SIGHUP');
|
||||
};
|
||||
process.on('SIGTERM', forwardSigTerm);
|
||||
process.on('SIGINT', forwardSigInt);
|
||||
process.on('SIGHUP', forwardSighup);
|
||||
|
||||
const cleanupSignalHandlers = () => {
|
||||
process.removeListener('SIGTERM', forwardSigTerm);
|
||||
process.removeListener('SIGINT', forwardSigInt);
|
||||
process.removeListener('SIGHUP', forwardSighup);
|
||||
};
|
||||
|
||||
claude.on('exit', (code, signal) => {
|
||||
cleanupSignalHandlers();
|
||||
proxy.kill('SIGTERM');
|
||||
if (signal) process.kill(process.pid, signal as NodeJS.Signals);
|
||||
else process.exit(code || 0);
|
||||
});
|
||||
|
||||
claude.on('error', (error) => {
|
||||
console.error(fail(`Claude CLI error: ${error}`));
|
||||
cleanupSignalHandlers();
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === 'EACCES') {
|
||||
console.error(fail(`Claude CLI is not executable: ${claudeCli}`));
|
||||
console.error(' Check file permissions and executable bit.');
|
||||
} else if (err.code === 'ENOENT') {
|
||||
if (isPowerShellScript) {
|
||||
console.error(fail('PowerShell executable not found (required for .ps1 wrapper launch).'));
|
||||
console.error(' Ensure powershell.exe is available in PATH.');
|
||||
} else if (needsShell) {
|
||||
console.error(fail('Windows command shell not found for Claude wrapper launch.'));
|
||||
console.error(' Ensure cmd.exe is available and accessible.');
|
||||
} else {
|
||||
console.error(fail(`Claude CLI not found: ${claudeCli}`));
|
||||
}
|
||||
} else {
|
||||
console.error(fail(`Claude CLI error: ${err.message}`));
|
||||
}
|
||||
proxy.kill('SIGTERM');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Also handle parent process termination
|
||||
process.once('SIGTERM', () => {
|
||||
proxy.kill('SIGTERM');
|
||||
claude.kill('SIGTERM');
|
||||
});
|
||||
|
||||
process.once('SIGINT', () => {
|
||||
proxy.kill('SIGTERM');
|
||||
claude.kill('SIGTERM');
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Main Execution ==========
|
||||
@@ -264,6 +315,10 @@ async function showCachedUpdateNotification(): Promise<boolean> {
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// Register target adapters
|
||||
registerTarget(new ClaudeAdapter());
|
||||
registerTarget(new DroidAdapter());
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Initialize UI colors early to ensure consistent colored output
|
||||
@@ -385,7 +440,8 @@ async function main(): Promise<void> {
|
||||
|
||||
// Special case: version command (check BEFORE profile detection)
|
||||
if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') {
|
||||
handleVersionCommand();
|
||||
await handleVersionCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: help command
|
||||
@@ -561,33 +617,6 @@ async function main(): Promise<void> {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
// Special case: headless delegation (-p flag)
|
||||
if (args.includes('-p') || args.includes('--prompt')) {
|
||||
// CLIProxy profiles (codex/gemini/agy/etc, including user variants) must stay on
|
||||
// the normal CLIProxy path so provider-specific flags (e.g. --effort/--thinking)
|
||||
// and proxy chains are applied consistently.
|
||||
let shouldUseDelegation = true;
|
||||
const { profile } = detectProfile(args);
|
||||
try {
|
||||
const ProfileDetectorModule = await import('./auth/profile-detector');
|
||||
const ProfileDetector = ProfileDetectorModule.default;
|
||||
const detector = new ProfileDetector();
|
||||
const profileInfo = detector.detectProfileType(profile);
|
||||
if (profileInfo.type === 'cliproxy') {
|
||||
shouldUseDelegation = false;
|
||||
}
|
||||
} catch {
|
||||
// Best effort detection only; keep delegation fallback behavior.
|
||||
}
|
||||
|
||||
if (shouldUseDelegation) {
|
||||
const { DelegationHandler } = await import('./delegation/delegation-handler');
|
||||
const handler = new DelegationHandler();
|
||||
await handler.route(args);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// First-time install: offer setup wizard for interactive users
|
||||
// Check independently of recovery status (user may have empty config.yaml)
|
||||
// Skip if headless, CI, or non-TTY environment
|
||||
@@ -599,16 +628,6 @@ async function main(): Promise<void> {
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Detect profile
|
||||
const { profile, remainingArgs } = detectProfile(args);
|
||||
|
||||
// Detect Claude CLI first (needed for all paths)
|
||||
const claudeCli = detectClaudeCli();
|
||||
if (!claudeCli) {
|
||||
await ErrorManager.showClaudeNotFound();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Use ProfileDetector to determine profile type
|
||||
const ProfileDetectorModule = await import('./auth/profile-detector');
|
||||
const ProfileDetector = ProfileDetectorModule.default;
|
||||
@@ -620,7 +639,124 @@ async function main(): Promise<void> {
|
||||
const detector = new ProfileDetector();
|
||||
|
||||
try {
|
||||
// Detect profile (strip --target flags before profile detection)
|
||||
const cleanArgs = stripTargetFlag(args);
|
||||
const { profile, remainingArgs } = detectProfile(cleanArgs);
|
||||
const profileInfo = detector.detectProfileType(profile);
|
||||
let resolvedTarget: ReturnType<typeof resolveTargetType>;
|
||||
try {
|
||||
resolvedTarget = resolveTargetType(
|
||||
args,
|
||||
profileInfo.target ? { target: profileInfo.target } : undefined
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(fail((error as Error).message));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect Claude CLI (needed for claude target and all CLIProxy-derived flows)
|
||||
const claudeCliRaw = detectClaudeCli();
|
||||
if (resolvedTarget === 'claude' && !claudeCliRaw) {
|
||||
await ErrorManager.showClaudeNotFound();
|
||||
process.exit(1);
|
||||
}
|
||||
const claudeCli = claudeCliRaw || '';
|
||||
|
||||
// Resolve non-claude target adapter once.
|
||||
const targetAdapter = resolvedTarget !== 'claude' ? getTarget(resolvedTarget) : null;
|
||||
|
||||
// Preflight unsupported profile/target combinations BEFORE binary detection,
|
||||
// so users get the most actionable error even when the target CLI is not installed.
|
||||
if (resolvedTarget !== 'claude') {
|
||||
if (!targetAdapter) {
|
||||
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (profileInfo.type === 'cliproxy' && !targetAdapter.supportsProfileType('cliproxy')) {
|
||||
console.error(fail(`${targetAdapter.displayName} does not support CLIProxy profiles`));
|
||||
console.error(info('Use a settings-based profile with --target instead'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (profileInfo.type === 'copilot' && !targetAdapter.supportsProfileType('copilot')) {
|
||||
console.error(fail(`${targetAdapter.displayName} does not support Copilot profiles`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (profileInfo.type === 'account' && !targetAdapter.supportsProfileType('account')) {
|
||||
console.error(fail(`${targetAdapter.displayName} does not support account-based profiles`));
|
||||
console.error(info('Use a settings-based profile with --target instead'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// GLMT always requires Claude target because it depends on embedded proxy flow.
|
||||
if (profileInfo.type === 'settings' && profileInfo.name === 'glmt') {
|
||||
console.error(fail(`${targetAdapter.displayName} does not support GLMT proxy profiles`));
|
||||
console.error(
|
||||
info('Use --target claude for glmt, or switch to a direct API profile (glm/kimi)')
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (profileInfo.type === 'default') {
|
||||
if (!targetAdapter.supportsProfileType('default')) {
|
||||
console.error(fail(`${targetAdapter.displayName} does not support default profile mode`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// For default mode, Droid requires explicit credentials from environment.
|
||||
if (resolvedTarget === 'droid') {
|
||||
const baseUrl = process.env['ANTHROPIC_BASE_URL'] || '';
|
||||
const apiKey = process.env['ANTHROPIC_AUTH_TOKEN'] || '';
|
||||
if (!baseUrl.trim() || !apiKey.trim()) {
|
||||
console.error(
|
||||
fail(
|
||||
`${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN`
|
||||
)
|
||||
);
|
||||
console.error(info('Use a settings-based profile instead: ccs glm --target droid'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For non-claude targets, verify target binary exists once and pass it through.
|
||||
const targetBinaryInfo = targetAdapter?.detectBinary() ?? null;
|
||||
if (resolvedTarget !== 'claude' && !targetBinaryInfo) {
|
||||
const displayName = targetAdapter?.displayName || resolvedTarget;
|
||||
console.error(fail(`${displayName} CLI not found.`));
|
||||
if (resolvedTarget === 'droid') {
|
||||
console.error(info('Install: npm i -g @factory/cli'));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Best-effort: prune stale Droid model entries at runtime so settings.json stays clean.
|
||||
if (resolvedTarget === 'droid') {
|
||||
try {
|
||||
const allProfiles = detector.getAllProfiles();
|
||||
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}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: headless delegation (-p/--prompt)
|
||||
// Keep existing behavior for Claude targets only; non-claude targets must continue
|
||||
// through normal adapter dispatch logic.
|
||||
if (args.includes('-p') || args.includes('--prompt')) {
|
||||
const shouldUseDelegation = resolvedTarget === 'claude' && profileInfo.type !== 'cliproxy';
|
||||
if (shouldUseDelegation) {
|
||||
const { DelegationHandler } = await import('./delegation/delegation-handler');
|
||||
const handler = new DelegationHandler();
|
||||
await handler.route(cleanArgs);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (profileInfo.type === 'cliproxy') {
|
||||
// CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants
|
||||
@@ -721,6 +857,15 @@ async function main(): Promise<void> {
|
||||
|
||||
// Check if this is GLMT profile (requires proxy)
|
||||
if (profileInfo.name === 'glmt') {
|
||||
if (resolvedTarget !== 'claude') {
|
||||
console.error(
|
||||
fail(`${targetAdapter?.displayName || 'Target'} does not support GLMT proxy profiles`)
|
||||
);
|
||||
console.error(
|
||||
info('Use --target claude for glmt, or switch to a direct API profile (glm/kimi)')
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
// GLMT FLOW: Settings-based with embedded proxy for thinking support
|
||||
await execClaudeWithProxy(claudeCli, profileInfo.name, remainingArgs);
|
||||
} else {
|
||||
@@ -753,6 +898,27 @@ async function main(): Promise<void> {
|
||||
...imageAnalysisEnv,
|
||||
CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider
|
||||
};
|
||||
|
||||
// Dispatch through target adapter for non-claude targets
|
||||
if (resolvedTarget !== 'claude') {
|
||||
const adapter = targetAdapter;
|
||||
if (!adapter) {
|
||||
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
|
||||
process.exit(1);
|
||||
}
|
||||
const creds: TargetCredentials = {
|
||||
profile: profileInfo.name,
|
||||
baseUrl: settingsEnv['ANTHROPIC_BASE_URL'] || '',
|
||||
apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || '',
|
||||
model: settingsEnv['ANTHROPIC_MODEL'],
|
||||
};
|
||||
await adapter.prepareCredentials(creds);
|
||||
const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs);
|
||||
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
|
||||
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs], envVars);
|
||||
}
|
||||
} else if (profileInfo.type === 'account') {
|
||||
@@ -790,6 +956,40 @@ async function main(): Promise<void> {
|
||||
CCS_WEBSEARCH_SKIP: '1',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||
};
|
||||
|
||||
// Dispatch through target adapter for non-claude targets
|
||||
if (resolvedTarget !== 'claude') {
|
||||
const adapter = targetAdapter;
|
||||
if (!adapter) {
|
||||
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
|
||||
process.exit(1);
|
||||
}
|
||||
if (!adapter.supportsProfileType('default')) {
|
||||
console.error(fail(`${adapter.displayName} does not support default profile mode`));
|
||||
process.exit(1);
|
||||
}
|
||||
const creds: TargetCredentials = {
|
||||
profile: 'default',
|
||||
baseUrl: process.env['ANTHROPIC_BASE_URL'] || '',
|
||||
apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '',
|
||||
model: process.env['ANTHROPIC_MODEL'],
|
||||
};
|
||||
if (!creds.baseUrl || !creds.apiKey) {
|
||||
console.error(
|
||||
fail(
|
||||
`${adapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN`
|
||||
)
|
||||
);
|
||||
console.error(info('Use a settings-based profile instead: ccs glm --target droid'));
|
||||
process.exit(1);
|
||||
}
|
||||
await adapter.prepareCredentials(creds);
|
||||
const targetArgs = adapter.buildArgs('default', remainingArgs);
|
||||
const targetEnv = adapter.buildEnv(creds, 'default');
|
||||
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
execClaude(claudeCli, remainingArgs, envVars);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -820,12 +1020,19 @@ process.on('unhandledRejection', (reason: unknown) => {
|
||||
// Handle process termination signals for cleanup
|
||||
process.on('SIGTERM', () => {
|
||||
runCleanup();
|
||||
process.exit(0);
|
||||
// If a target exec path registered additional signal listeners, let those
|
||||
// listeners forward/coordinate child shutdown and final exit codes.
|
||||
if (process.listenerCount('SIGTERM') <= 1) {
|
||||
process.exit(143); // 128 + SIGTERM(15)
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
runCleanup();
|
||||
process.exit(130); // 128 + SIGINT(2)
|
||||
// Same coordination rule as SIGTERM.
|
||||
if (process.listenerCount('SIGINT') <= 1) {
|
||||
process.exit(130); // 128 + SIGINT(2)
|
||||
}
|
||||
});
|
||||
|
||||
// Run main
|
||||
|
||||
+113
-39
@@ -17,6 +17,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
import { getCliproxyDir } from './config-generator';
|
||||
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
||||
import { CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||
@@ -31,6 +32,10 @@ interface SessionLock {
|
||||
version?: string;
|
||||
/** Backend type running (original vs plus) */
|
||||
backend?: 'original' | 'plus';
|
||||
/** Target summary for active sessions ('mixed' when multiple targets share the proxy) */
|
||||
target?: string;
|
||||
/** Per-session target metadata */
|
||||
sessionTargets?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Generate unique session ID */
|
||||
@@ -46,6 +51,34 @@ function getSessionLockPathForPort(port: number): string {
|
||||
return path.join(getCliproxyDir(), `sessions-${port}.json`);
|
||||
}
|
||||
|
||||
function ensureCliproxyDir(): string {
|
||||
const dir = getCliproxyDir();
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function withSessionTrackerLock<T>(fn: () => T): T {
|
||||
const dir = ensureCliproxyDir();
|
||||
let release: (() => void) | undefined;
|
||||
|
||||
try {
|
||||
release = lockfile.lockSync(dir, {
|
||||
stale: 10000,
|
||||
}) as () => void;
|
||||
return fn();
|
||||
} finally {
|
||||
if (release) {
|
||||
try {
|
||||
release();
|
||||
} catch {
|
||||
// Best-effort release
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get path to session lock file (default port) - kept for future use */
|
||||
function _getSessionLockPath(): string {
|
||||
return getSessionLockPathForPort(CLIPROXY_DEFAULT_PORT);
|
||||
@@ -85,13 +118,24 @@ function readSessionLock(): SessionLock | null {
|
||||
return readSessionLockForPort(CLIPROXY_DEFAULT_PORT);
|
||||
}
|
||||
|
||||
function getTargetSummary(lock: SessionLock): string | undefined {
|
||||
const targets = lock.sessionTargets ? Object.values(lock.sessionTargets).filter(Boolean) : [];
|
||||
if (targets.length === 0) {
|
||||
return lock.target;
|
||||
}
|
||||
|
||||
const unique = new Set(targets);
|
||||
if (unique.size === 1) {
|
||||
return targets[0];
|
||||
}
|
||||
return 'mixed';
|
||||
}
|
||||
|
||||
/** Write session lock file for specific port */
|
||||
function writeSessionLockForPort(lock: SessionLock): void {
|
||||
const lockPath = getSessionLockPathForPort(lock.port);
|
||||
const dir = path.dirname(lockPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
ensureCliproxyDir();
|
||||
lock.target = getTargetSummary(lock);
|
||||
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
@@ -184,16 +228,24 @@ export function registerSession(
|
||||
port: number,
|
||||
proxyPid: number,
|
||||
version?: string,
|
||||
backend?: 'original' | 'plus'
|
||||
backend?: 'original' | 'plus',
|
||||
target?: string
|
||||
): string {
|
||||
const sessionId = generateSessionId();
|
||||
const existingLock = readSessionLockForPort(port);
|
||||
withSessionTrackerLock(() => {
|
||||
const existingLock = readSessionLockForPort(port);
|
||||
|
||||
if (existingLock && existingLock.port === port && existingLock.pid === proxyPid) {
|
||||
// Add to existing sessions
|
||||
existingLock.sessions.push(sessionId);
|
||||
if (target) {
|
||||
existingLock.sessionTargets = existingLock.sessionTargets || {};
|
||||
existingLock.sessionTargets[sessionId] = target;
|
||||
}
|
||||
writeSessionLockForPort(existingLock);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingLock && existingLock.port === port && existingLock.pid === proxyPid) {
|
||||
// Add to existing sessions
|
||||
existingLock.sessions.push(sessionId);
|
||||
writeSessionLockForPort(existingLock);
|
||||
} else {
|
||||
// Create new lock (first session for this proxy)
|
||||
const newLock: SessionLock = {
|
||||
port,
|
||||
@@ -202,9 +254,11 @@ export function registerSession(
|
||||
startedAt: new Date().toISOString(),
|
||||
version,
|
||||
backend,
|
||||
target,
|
||||
sessionTargets: target ? { [sessionId]: target } : undefined,
|
||||
};
|
||||
writeSessionLockForPort(newLock);
|
||||
}
|
||||
});
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
@@ -218,48 +272,66 @@ export function registerSession(
|
||||
export function unregisterSession(sessionId: string, port?: number): boolean {
|
||||
// If port provided, use port-specific lookup
|
||||
if (port !== undefined) {
|
||||
const lock = readSessionLockForPort(port);
|
||||
return withSessionTrackerLock(() => {
|
||||
const lock = readSessionLockForPort(port);
|
||||
if (!lock) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const index = lock.sessions.indexOf(sessionId);
|
||||
if (index !== -1) {
|
||||
lock.sessions.splice(index, 1);
|
||||
}
|
||||
|
||||
if (lock.sessionTargets) {
|
||||
delete lock.sessionTargets[sessionId];
|
||||
if (Object.keys(lock.sessionTargets).length === 0) {
|
||||
delete lock.sessionTargets;
|
||||
}
|
||||
}
|
||||
|
||||
if (lock.sessions.length === 0) {
|
||||
deleteSessionLockForPort(port);
|
||||
return true;
|
||||
}
|
||||
|
||||
writeSessionLockForPort(lock);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: search default port (backward compat)
|
||||
return withSessionTrackerLock(() => {
|
||||
const lock = readSessionLock();
|
||||
if (!lock) {
|
||||
// No lock file - assume we're the only session
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove this session from the list
|
||||
const index = lock.sessions.indexOf(sessionId);
|
||||
if (index !== -1) {
|
||||
lock.sessions.splice(index, 1);
|
||||
}
|
||||
|
||||
if (lock.sessionTargets) {
|
||||
delete lock.sessionTargets[sessionId];
|
||||
if (Object.keys(lock.sessionTargets).length === 0) {
|
||||
delete lock.sessionTargets;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any sessions remain
|
||||
if (lock.sessions.length === 0) {
|
||||
deleteSessionLockForPort(port);
|
||||
// Last session - clean up lock file
|
||||
deleteSessionLock();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Other sessions still active - keep proxy running
|
||||
writeSessionLockForPort(lock);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fallback: search default port (backward compat)
|
||||
const lock = readSessionLock();
|
||||
if (!lock) {
|
||||
// No lock file - assume we're the only session
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove this session from the list
|
||||
const index = lock.sessions.indexOf(sessionId);
|
||||
if (index !== -1) {
|
||||
lock.sessions.splice(index, 1);
|
||||
}
|
||||
|
||||
// Check if any sessions remain
|
||||
if (lock.sessions.length === 0) {
|
||||
// Last session - clean up lock file
|
||||
deleteSessionLock();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Other sessions still active - keep proxy running
|
||||
writeSessionLockForPort(lock);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -420,6 +492,7 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): {
|
||||
sessionCount?: number;
|
||||
startedAt?: string;
|
||||
version?: string;
|
||||
target?: string;
|
||||
} {
|
||||
const lock = readSessionLockForPort(port);
|
||||
|
||||
@@ -440,6 +513,7 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): {
|
||||
sessionCount: lock.sessions.length,
|
||||
startedAt: lock.startedAt,
|
||||
version: lock.version,
|
||||
target: lock.target || 'claude',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -301,11 +301,24 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
// Flags
|
||||
printSubSection('Flags', [
|
||||
['--config-dir <path>', 'Use custom CCS config directory'],
|
||||
['--target <cli>', 'Target CLI: claude (default), droid'],
|
||||
['-h, --help', 'Show this help message'],
|
||||
['-v, --version', 'Show version and installation info'],
|
||||
['-sc, --shell-completion', 'Install shell auto-completion'],
|
||||
]);
|
||||
|
||||
// Aliases
|
||||
printSubSection('Aliases', [
|
||||
['ccsd <profile> [args]', 'Shorthand for: ccs <profile> --target droid'],
|
||||
]);
|
||||
|
||||
// Multi-target examples
|
||||
printSubSection('Multi-Target', [
|
||||
['ccs glm --target droid', 'Run GLM profile on Droid CLI'],
|
||||
['ccsd glm', 'Same as above (alias)'],
|
||||
['ccs glm', 'Run GLM profile on Claude Code (default)'],
|
||||
]);
|
||||
|
||||
// Configuration
|
||||
printConfigSection('Configuration', [
|
||||
['Config File:', isUnifiedMode() ? `${dirDisplay}/config.yaml` : `${dirDisplay}/config.json`],
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* Into a single config.yaml structure.
|
||||
*/
|
||||
|
||||
import type { TargetType } from '../targets/target-adapter';
|
||||
|
||||
/**
|
||||
* Unified config version.
|
||||
* Version 2 = YAML unified format
|
||||
@@ -59,6 +61,8 @@ export interface ProfileConfig {
|
||||
type: 'api';
|
||||
/** Path to settings file (e.g., "~/.ccs/glm.settings.json") */
|
||||
settings: string;
|
||||
/** Target CLI to use for this profile (default: 'claude') */
|
||||
target?: TargetType;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +89,8 @@ export interface CLIProxyVariantConfig {
|
||||
port?: number;
|
||||
/** Per-variant auth override (optional) */
|
||||
auth?: CLIProxyAuthConfig;
|
||||
/** Target CLI to use for this variant (default: 'claude') */
|
||||
target?: TargetType;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,6 +136,8 @@ export interface CompositeVariantConfig {
|
||||
port?: number;
|
||||
/** Per-variant auth override (optional) */
|
||||
auth?: CLIProxyAuthConfig;
|
||||
/** Target CLI to use for this composite variant (default: 'claude') */
|
||||
target?: TargetType;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Claude Adapter
|
||||
*
|
||||
* TargetAdapter implementation for Claude Code CLI.
|
||||
* Wraps existing detection, spawning, and execution logic.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from './target-adapter';
|
||||
import { detectClaudeCli, getClaudeCliInfo } from '../utils/claude-detector';
|
||||
import type { ProfileType } from '../types/profile';
|
||||
import { escapeShellArg, stripAnthropicEnv } from '../utils/shell-executor';
|
||||
import { ErrorManager } from '../utils/error-manager';
|
||||
import { getWebSearchHookEnv } from '../utils/websearch-manager';
|
||||
import { wireChildProcessSignals } from '../utils/signal-forwarder';
|
||||
|
||||
export class ClaudeAdapter implements TargetAdapter {
|
||||
readonly type: TargetType = 'claude';
|
||||
readonly displayName = 'Claude Code';
|
||||
|
||||
detectBinary(): TargetBinaryInfo | null {
|
||||
const info = getClaudeCliInfo();
|
||||
if (!info) return null;
|
||||
return { path: info.path, needsShell: info.needsShell };
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude uses env vars for credential delivery — no config file writes needed.
|
||||
*/
|
||||
async prepareCredentials(_creds: TargetCredentials): Promise<void> {
|
||||
// No-op: Claude receives credentials via environment variables
|
||||
}
|
||||
|
||||
buildArgs(_profile: string, userArgs: string[]): string[] {
|
||||
return userArgs;
|
||||
}
|
||||
|
||||
buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv {
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
|
||||
// For account/default profiles, strip ANTHROPIC_* from parent env to prevent
|
||||
// stale proxy config from interfering with native Claude API routing.
|
||||
const baseEnv =
|
||||
profileType === 'account' || profileType === 'default'
|
||||
? stripAnthropicEnv(process.env)
|
||||
: process.env;
|
||||
|
||||
const env: NodeJS.ProcessEnv = { ...baseEnv, ...webSearchEnv };
|
||||
|
||||
if (creds.envVars) {
|
||||
Object.assign(env, creds.envVars);
|
||||
}
|
||||
|
||||
if (creds.baseUrl) env['ANTHROPIC_BASE_URL'] = creds.baseUrl;
|
||||
if (creds.apiKey) env['ANTHROPIC_AUTH_TOKEN'] = creds.apiKey;
|
||||
if (creds.model) env['ANTHROPIC_MODEL'] = creds.model;
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
exec(
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
_options?: { cwd?: string; binaryInfo?: TargetBinaryInfo }
|
||||
): void {
|
||||
const claudeCli = detectClaudeCli();
|
||||
if (!claudeCli) {
|
||||
void ErrorManager.showClaudeNotFound();
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli);
|
||||
const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli);
|
||||
|
||||
let child: ChildProcess;
|
||||
if (isPowerShellScript) {
|
||||
child = spawn(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env,
|
||||
}
|
||||
);
|
||||
} else if (needsShell) {
|
||||
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
|
||||
child = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
child = spawn(claudeCli, args, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
wireChildProcessSignals(child, async (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EACCES') {
|
||||
console.error(`[X] Claude CLI is not executable: ${claudeCli}`);
|
||||
console.error(' Check file permissions and executable bit.');
|
||||
} else if (err.code === 'ENOENT') {
|
||||
if (isPowerShellScript) {
|
||||
console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).');
|
||||
console.error(' Ensure powershell.exe is available in PATH.');
|
||||
} else if (needsShell) {
|
||||
console.error('[X] Windows command shell not found for Claude wrapper launch.');
|
||||
console.error(' Ensure cmd.exe is available and accessible.');
|
||||
} else {
|
||||
await ErrorManager.showClaudeNotFound();
|
||||
}
|
||||
} else {
|
||||
console.error(`[X] Failed to start Claude CLI (${claudeCli}): ${err.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude supports all CCS profile types.
|
||||
*/
|
||||
supportsProfileType(_profileType: ProfileType): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Droid Adapter
|
||||
*
|
||||
* TargetAdapter implementation for Factory Droid CLI.
|
||||
* Writes credentials to ~/.factory/settings.json and spawns `droid -m custom:ccs-<profile>`.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from './target-adapter';
|
||||
import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector';
|
||||
import type { ProfileType } from '../types/profile';
|
||||
import { upsertCcsModel } from './droid-config-manager';
|
||||
import { escapeShellArg } from '../utils/shell-executor';
|
||||
import { wireChildProcessSignals } from '../utils/signal-forwarder';
|
||||
|
||||
export class DroidAdapter implements TargetAdapter {
|
||||
readonly type: TargetType = 'droid';
|
||||
readonly displayName = 'Factory Droid';
|
||||
|
||||
private validateCredentials(creds: TargetCredentials): void {
|
||||
if (!creds.baseUrl?.trim()) {
|
||||
throw new Error('Droid target requires ANTHROPIC_BASE_URL');
|
||||
}
|
||||
if (!creds.apiKey?.trim()) {
|
||||
throw new Error('Droid target requires ANTHROPIC_AUTH_TOKEN');
|
||||
}
|
||||
}
|
||||
|
||||
detectBinary(): TargetBinaryInfo | null {
|
||||
const info = getDroidBinaryInfo();
|
||||
if (!info) return null;
|
||||
|
||||
// Version compatibility check (non-blocking warning)
|
||||
checkDroidVersion(info.path);
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write CCS credentials to ~/.factory/settings.json as a custom model entry.
|
||||
* This is the key difference from Claude — Droid reads config files, not env vars.
|
||||
*/
|
||||
async prepareCredentials(creds: TargetCredentials): Promise<void> {
|
||||
this.validateCredentials(creds);
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
buildArgs(profile: string, userArgs: string[]): string[] {
|
||||
return ['-m', `custom:ccs-${profile}`, ...userArgs];
|
||||
}
|
||||
|
||||
/**
|
||||
* Droid uses config file for credentials — minimal env needed.
|
||||
*/
|
||||
buildEnv(_creds: TargetCredentials, _profileType: ProfileType): NodeJS.ProcessEnv {
|
||||
return { ...process.env };
|
||||
}
|
||||
|
||||
exec(
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
options?: { cwd?: string; binaryInfo?: TargetBinaryInfo }
|
||||
): void {
|
||||
const droidPath = options?.binaryInfo?.path || detectDroidCli();
|
||||
if (!droidPath) {
|
||||
console.error('[X] Droid CLI not found. Install: npm i -g @factory/cli');
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stat = fs.statSync(droidPath);
|
||||
if (!stat.isFile()) {
|
||||
console.error(`[X] Droid CLI path is not a file: ${droidPath}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
console.error(
|
||||
`[X] Droid CLI path is not accessible (${error.code || 'unknown'}): ${droidPath}`
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const isPowerShellScript = isWindows && /\.ps1$/i.test(droidPath);
|
||||
const needsShell = isWindows && /\.(cmd|bat)$/i.test(droidPath);
|
||||
|
||||
let child: ChildProcess;
|
||||
if (isPowerShellScript) {
|
||||
child = spawn(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', droidPath, ...args],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env,
|
||||
}
|
||||
);
|
||||
} else if (needsShell) {
|
||||
const cmdString = [droidPath, ...args].map(escapeShellArg).join(' ');
|
||||
child = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
child = spawn(droidPath, args, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
wireChildProcessSignals(child, (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EACCES') {
|
||||
console.error(`[X] Droid CLI is not executable: ${droidPath}`);
|
||||
console.error(' Check file permissions and executable bit.');
|
||||
} else if (err.code === 'ENOENT') {
|
||||
if (isPowerShellScript) {
|
||||
console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).');
|
||||
console.error(' Ensure powershell.exe is available in PATH.');
|
||||
} else if (needsShell) {
|
||||
console.error('[X] Windows command shell not found for Droid wrapper launch.');
|
||||
console.error(' Ensure cmd.exe is available and accessible.');
|
||||
} else {
|
||||
console.error(`[X] Droid CLI not found: ${droidPath}`);
|
||||
console.error(' Install: npm i -g @factory/cli');
|
||||
}
|
||||
} else {
|
||||
console.error(`[X] Failed to start Droid CLI (${droidPath}):`, err.message);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Droid currently supports direct settings-based and default flows only.
|
||||
*/
|
||||
supportsProfileType(profileType: ProfileType): boolean {
|
||||
return profileType === 'settings' || profileType === 'default';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* Droid Config Manager
|
||||
*
|
||||
* Read/write ~/.factory/settings.json safely.
|
||||
* Only touches ccs-* prefixed entries in customModels[].
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
|
||||
const CCS_MODEL_PREFIX = 'ccs-';
|
||||
const CCS_DISPLAY_PREFIX = 'CCS ';
|
||||
|
||||
/** Lock configuration for concurrent write safety */
|
||||
const LOCK_STALE_MS = 10000;
|
||||
const LOCK_RETRY_MIN_MS = 200;
|
||||
const LOCK_RETRY_MAX_MS = 1000;
|
||||
|
||||
/**
|
||||
* Validate profile name to prevent filesystem/security issues.
|
||||
* Only alphanumeric, underscore, hyphen allowed.
|
||||
*/
|
||||
function isValidProfileName(profile: string): boolean {
|
||||
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`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface DroidCustomModel {
|
||||
model: string;
|
||||
displayName: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
provider: 'anthropic' | 'openai' | 'generic-chat-completion-api';
|
||||
maxOutputTokens?: number;
|
||||
}
|
||||
|
||||
interface DroidSettings {
|
||||
customModels?: DroidCustomModelEntry[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DroidCustomModelEntry {
|
||||
model: string;
|
||||
displayName: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
provider: string;
|
||||
maxOutputTokens?: number;
|
||||
/** Internal alias used by CCS for lookup. Stored as the model's display name prefix. */
|
||||
}
|
||||
|
||||
function isSupportedProvider(value: string): value is DroidCustomModel['provider'] {
|
||||
return value === 'anthropic' || value === 'openai' || value === 'generic-chat-completion-api';
|
||||
}
|
||||
|
||||
function isDroidCustomModelEntry(value: unknown): value is DroidCustomModelEntry {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof record.displayName === 'string' &&
|
||||
record.displayName.trim() !== '' &&
|
||||
typeof record.model === 'string' &&
|
||||
typeof record.baseUrl === 'string' &&
|
||||
typeof record.apiKey === 'string' &&
|
||||
typeof record.provider === 'string' &&
|
||||
record.provider.trim() !== ''
|
||||
);
|
||||
}
|
||||
|
||||
function isManagedDisplayName(displayName: string): boolean {
|
||||
return displayName.startsWith(CCS_DISPLAY_PREFIX) || displayName.startsWith(CCS_MODEL_PREFIX);
|
||||
}
|
||||
|
||||
function parseManagedProfile(displayName: string): string | null {
|
||||
let profile: string | null = null;
|
||||
|
||||
if (displayName.startsWith(CCS_DISPLAY_PREFIX)) {
|
||||
profile = displayName.slice(CCS_DISPLAY_PREFIX.length).trim();
|
||||
} else if (displayName.startsWith(CCS_MODEL_PREFIX)) {
|
||||
profile = displayName.slice(CCS_MODEL_PREFIX.length).trim();
|
||||
}
|
||||
|
||||
if (!profile || !isValidProfileName(profile)) return null;
|
||||
return profile;
|
||||
}
|
||||
|
||||
function asModelEntry(value: unknown): DroidCustomModelEntry | null {
|
||||
return isDroidCustomModelEntry(value) ? value : null;
|
||||
}
|
||||
|
||||
function normalizeCustomModels(value: unknown): DroidCustomModelEntry[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((entry) => asModelEntry(entry))
|
||||
.filter((entry): entry is DroidCustomModelEntry => !!entry);
|
||||
}
|
||||
|
||||
// Accept legacy object-map shapes and normalize to array.
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.values(value)
|
||||
.map((entry) => asModelEntry(entry))
|
||||
.filter((entry): entry is DroidCustomModelEntry => !!entry);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to ~/.factory/settings.json.
|
||||
* Respects CCS_HOME for test isolation (uses CCS_HOME/.factory/ in tests).
|
||||
*/
|
||||
function getFactoryDir(): string {
|
||||
const base = process.env.CCS_HOME || os.homedir();
|
||||
return path.join(base, '.factory');
|
||||
}
|
||||
|
||||
function getSettingsPath(): string {
|
||||
return path.join(getFactoryDir(), 'settings.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure ~/.factory/ directory exists.
|
||||
*/
|
||||
function ensureFactoryDir(): void {
|
||||
const dir = getFactoryDir();
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code !== 'EEXIST') throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getNoFollowFlag(): number {
|
||||
const candidate = (fs.constants as Record<string, number>)['O_NOFOLLOW'];
|
||||
if (process.platform !== 'win32' && typeof candidate === 'number') {
|
||||
return candidate;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function openFileNoFollow(filePath: string, flags: number, mode?: number): number {
|
||||
const safeFlags = flags | getNoFollowFlag();
|
||||
if (mode === undefined) {
|
||||
return fs.openSync(filePath, safeFlags);
|
||||
}
|
||||
return fs.openSync(filePath, safeFlags, mode);
|
||||
}
|
||||
|
||||
function readFileUtf8NoFollow(filePath: string): string {
|
||||
const fd = openFileNoFollow(filePath, fs.constants.O_RDONLY);
|
||||
try {
|
||||
const stat = fs.fstatSync(fd);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error('Refusing to read: settings.json is not a regular file');
|
||||
}
|
||||
return fs.readFileSync(fd, 'utf8');
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read ~/.factory/settings.json, creating empty structure if missing.
|
||||
*/
|
||||
function readDroidSettings(): DroidSettings {
|
||||
const settingsPath = getSettingsPath();
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
return { customModels: [] };
|
||||
}
|
||||
|
||||
const fileStat = fs.lstatSync(settingsPath);
|
||||
if (fileStat.isSymbolicLink()) {
|
||||
throw new Error('Refusing to read: settings.json is a symlink');
|
||||
}
|
||||
if (!fileStat.isFile()) {
|
||||
throw new Error('Refusing to read: settings.json is not a regular file');
|
||||
}
|
||||
|
||||
const raw = readFileUtf8NoFollow(settingsPath);
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as DroidSettings;
|
||||
return {
|
||||
...parsed,
|
||||
customModels: normalizeCustomModels((parsed as { customModels?: unknown }).customModels),
|
||||
};
|
||||
} catch {
|
||||
// Corrupted file — preserve as backup, start fresh
|
||||
const backup = settingsPath + '.bak';
|
||||
try {
|
||||
fs.copyFileSync(settingsPath, backup);
|
||||
fs.chmodSync(backup, 0o600); // Secure backup permissions
|
||||
console.warn(`[!] Corrupted ${settingsPath}, backed up to ${backup}`);
|
||||
} catch (error) {
|
||||
console.warn(`[!] Corrupted ${settingsPath}; backup failed: ${(error as Error).message}`);
|
||||
}
|
||||
return { customModels: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireFactoryLock(retries: number): Promise<() => Promise<void>> {
|
||||
ensureFactoryDir();
|
||||
const factoryDir = getFactoryDir();
|
||||
try {
|
||||
return await lockfile.lock(factoryDir, {
|
||||
stale: LOCK_STALE_MS,
|
||||
retries: { retries, minTimeout: LOCK_RETRY_MIN_MS, maxTimeout: LOCK_RETRY_MAX_MS },
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to lock Droid settings directory (${factoryDir}): ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function fsyncDir(dirPath: string): void {
|
||||
try {
|
||||
const dirFd = fs.openSync(dirPath, fs.constants.O_RDONLY);
|
||||
try {
|
||||
fs.fsyncSync(dirFd);
|
||||
} finally {
|
||||
fs.closeSync(dirFd);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort directory fsync (platform dependent).
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write ~/.factory/settings.json atomically with safe permissions.
|
||||
* Uses temp file + rename for atomicity on same filesystem.
|
||||
*/
|
||||
function writeDroidSettings(settings: DroidSettings): void {
|
||||
ensureFactoryDir();
|
||||
const settingsPath = getSettingsPath();
|
||||
|
||||
// Refuse to write if target is a symlink (prevents symlink attacks)
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const stat = fs.lstatSync(settingsPath);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error('Refusing to write: settings.json is a symlink');
|
||||
}
|
||||
}
|
||||
|
||||
const tmpPath = settingsPath + '.tmp';
|
||||
if (fs.existsSync(tmpPath)) {
|
||||
const tmpStat = fs.lstatSync(tmpPath);
|
||||
if (tmpStat.isSymbolicLink()) {
|
||||
throw new Error('Refusing to write: settings.json.tmp is a symlink');
|
||||
}
|
||||
}
|
||||
|
||||
const payload = JSON.stringify(
|
||||
{
|
||||
...settings,
|
||||
customModels: normalizeCustomModels((settings as { customModels?: unknown }).customModels),
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
const fd = openFileNoFollow(
|
||||
tmpPath,
|
||||
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC,
|
||||
0o600
|
||||
);
|
||||
try {
|
||||
const tmpFdStat = fs.fstatSync(fd);
|
||||
if (!tmpFdStat.isFile()) {
|
||||
throw new Error('Refusing to write: settings.json.tmp is not a regular file');
|
||||
}
|
||||
fs.writeFileSync(fd, payload + '\n', { encoding: 'utf8' });
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
fs.renameSync(tmpPath, settingsPath);
|
||||
fsyncDir(path.dirname(settingsPath));
|
||||
|
||||
// Fix permissions on existing file if world-readable
|
||||
try {
|
||||
const stat = fs.statSync(settingsPath);
|
||||
|
||||
if (stat.mode & 0o077) {
|
||||
fs.chmodSync(settingsPath, 0o600);
|
||||
console.warn('[!] Fixed permissions on ~/.factory/settings.json (was world-readable)');
|
||||
}
|
||||
} catch {
|
||||
// Best-effort permission check
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
validateProfileName(profile);
|
||||
ensureFactoryDir();
|
||||
|
||||
let release: (() => Promise<void>) | undefined;
|
||||
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
|
||||
);
|
||||
|
||||
if (idx >= 0) {
|
||||
settings.customModels[idx] = entry;
|
||||
} else {
|
||||
settings.customModels.push(entry);
|
||||
}
|
||||
|
||||
writeDroidSettings(settings);
|
||||
} finally {
|
||||
if (release) await release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a CCS-managed custom model entry.
|
||||
*/
|
||||
export async function removeCcsModel(profile: string): Promise<void> {
|
||||
validateProfileName(profile);
|
||||
ensureFactoryDir();
|
||||
const settingsPath = getSettingsPath();
|
||||
|
||||
let release: (() => Promise<void>) | undefined;
|
||||
try {
|
||||
release = await acquireFactoryLock(3);
|
||||
if (!fs.existsSync(settingsPath)) return;
|
||||
|
||||
const settings = readDroidSettings();
|
||||
settings.customModels = normalizeCustomModels(settings.customModels);
|
||||
|
||||
settings.customModels = settings.customModels.filter(
|
||||
(m) => parseManagedProfile(m.displayName) !== profile
|
||||
);
|
||||
|
||||
writeDroidSettings(settings);
|
||||
} finally {
|
||||
if (release) await release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all CCS-managed custom model entries.
|
||||
*/
|
||||
export async function listCcsModels(): Promise<Map<string, DroidCustomModel>> {
|
||||
const result = new Map<string, DroidCustomModel>();
|
||||
const settings = readDroidSettings();
|
||||
for (const entry of normalizeCustomModels(settings.customModels)) {
|
||||
const profile = parseManagedProfile(entry.displayName);
|
||||
if (!profile) continue;
|
||||
if (!isSupportedProvider(entry.provider)) continue;
|
||||
|
||||
result.set(profile, {
|
||||
...entry,
|
||||
provider: entry.provider,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune orphaned CCS entries from settings.json.
|
||||
* Removes ccs-* entries whose profile no longer exists in active profiles.
|
||||
*/
|
||||
export async function pruneOrphanedModels(activeProfiles: string[]): Promise<number> {
|
||||
// Validate all profile names before pruning
|
||||
activeProfiles.forEach((profile) => validateProfileName(profile));
|
||||
|
||||
ensureFactoryDir();
|
||||
const settingsPath = getSettingsPath();
|
||||
|
||||
let release: (() => Promise<void>) | undefined;
|
||||
let removed = 0;
|
||||
|
||||
try {
|
||||
release = await acquireFactoryLock(3);
|
||||
if (!fs.existsSync(settingsPath)) return 0;
|
||||
|
||||
const settings = readDroidSettings();
|
||||
settings.customModels = normalizeCustomModels(settings.customModels);
|
||||
|
||||
const before = settings.customModels.length;
|
||||
settings.customModels = settings.customModels.filter((m) => {
|
||||
const profile = parseManagedProfile(m.displayName);
|
||||
if (profile) {
|
||||
return activeProfiles.includes(profile);
|
||||
}
|
||||
|
||||
// Drop malformed managed entries; keep user-managed entries.
|
||||
return !isManagedDisplayName(m.displayName);
|
||||
});
|
||||
|
||||
removed = before - settings.customModels.length;
|
||||
if (removed > 0) {
|
||||
writeDroidSettings(settings);
|
||||
}
|
||||
} finally {
|
||||
if (release) await release();
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Droid CLI Detector
|
||||
*
|
||||
* Detects Factory Droid CLI binary in PATH.
|
||||
* Mirrors claude-detector.ts pattern.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { execSync, execFileSync } from 'child_process';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
import { TargetBinaryInfo } from './target-adapter';
|
||||
|
||||
/**
|
||||
* Detect Droid CLI executable.
|
||||
*
|
||||
* Priority:
|
||||
* 1. CCS_DROID_PATH env var (user override)
|
||||
* 2. PATH lookup via which/where.exe
|
||||
*/
|
||||
export function detectDroidCli(): string | null {
|
||||
// Priority 1: CCS_DROID_PATH environment variable
|
||||
if (process.env.CCS_DROID_PATH) {
|
||||
const customPath = expandPath(process.env.CCS_DROID_PATH);
|
||||
try {
|
||||
if (fs.statSync(customPath).isFile()) {
|
||||
return customPath;
|
||||
}
|
||||
console.warn('[!] CCS_DROID_PATH points to a directory, not a file:', customPath);
|
||||
console.warn(' Refusing PATH fallback while CCS_DROID_PATH is explicitly set.');
|
||||
return null;
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code === 'ENOENT') {
|
||||
console.warn('[!] Warning: CCS_DROID_PATH is set but file not found:', customPath);
|
||||
} else {
|
||||
console.warn(
|
||||
`[!] Warning: CCS_DROID_PATH is not accessible (${error.code || 'unknown error'}):`,
|
||||
customPath
|
||||
);
|
||||
}
|
||||
console.warn(' Refusing PATH fallback while CCS_DROID_PATH is explicitly set.');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Resolve 'droid' from PATH
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
try {
|
||||
const cmd = isWindows ? 'where.exe droid' : 'which droid';
|
||||
const result = execSync(cmd, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
|
||||
const matches = result
|
||||
.split('\n')
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p);
|
||||
|
||||
const candidates = isWindows
|
||||
? [
|
||||
...matches.filter((p) => /\.(exe|cmd|bat|ps1)$/i.test(p)),
|
||||
...matches.filter((p) => !/\.(exe|cmd|bat|ps1)$/i.test(p)),
|
||||
]
|
||||
: matches;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (fs.statSync(candidate).isFile()) {
|
||||
return candidate;
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable or disappearing path candidates and try next one
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// droid not in PATH
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Droid CLI binary info for target adapter.
|
||||
*/
|
||||
export function getDroidBinaryInfo(): TargetBinaryInfo | null {
|
||||
const droidPath = detectDroidCli();
|
||||
if (!droidPath) return null;
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(droidPath);
|
||||
|
||||
return { path: droidPath, needsShell };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Droid CLI version for compatibility warnings.
|
||||
* Non-blocking — logs warning and continues.
|
||||
*/
|
||||
export function checkDroidVersion(droidPath: string): void {
|
||||
try {
|
||||
const version = execFileSync(droidPath, ['--version'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
|
||||
// Parse semver major version
|
||||
const match = version.match(/(\d+)\.\d+\.\d+/);
|
||||
if (match) {
|
||||
const major = parseInt(match[1]);
|
||||
if (major >= 2) {
|
||||
console.warn(
|
||||
`[!] Droid version ${version} not verified with CCS. Config format may differ.`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Version check is best-effort — don't block execution
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Target Adapter Module
|
||||
*
|
||||
* Re-exports for convenient access to target adapter types and registry.
|
||||
*/
|
||||
|
||||
export type {
|
||||
TargetAdapter,
|
||||
TargetBinaryInfo,
|
||||
TargetCredentials,
|
||||
TargetType,
|
||||
} from './target-adapter';
|
||||
export {
|
||||
registerTarget,
|
||||
getTarget,
|
||||
getDefaultTarget,
|
||||
hasTarget,
|
||||
getRegisteredTargets,
|
||||
} from './target-registry';
|
||||
export { ClaudeAdapter } from './claude-adapter';
|
||||
export { DroidAdapter } from './droid-adapter';
|
||||
export { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector';
|
||||
export {
|
||||
upsertCcsModel,
|
||||
removeCcsModel,
|
||||
listCcsModels,
|
||||
pruneOrphanedModels,
|
||||
} from './droid-config-manager';
|
||||
export type { DroidCustomModel } from './droid-config-manager';
|
||||
export { resolveTargetType, stripTargetFlag } from './target-resolver';
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Target Adapter Interface
|
||||
*
|
||||
* Abstraction layer for different CLI targets (Claude, Droid, etc.).
|
||||
* Profile resolution is target-agnostic — only the "last mile" execution differs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported CLI target types.
|
||||
* 'claude' is the default; additional targets register via target-registry.
|
||||
*/
|
||||
import type { ProfileType } from '../types/profile';
|
||||
|
||||
export type TargetType = 'claude' | 'droid';
|
||||
|
||||
/**
|
||||
* Credentials resolved by CCS profile system, ready for delivery to target CLI.
|
||||
*/
|
||||
export interface TargetCredentials {
|
||||
/** CCS profile name (e.g., 'gemini', 'codex', 'glm') */
|
||||
profile: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
provider?: 'anthropic' | 'openai' | 'generic-chat-completion-api';
|
||||
/** Additional env vars from profile resolution (websearch, hooks, etc.) */
|
||||
envVars?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of detecting a target CLI binary on the system.
|
||||
*/
|
||||
export interface TargetBinaryInfo {
|
||||
path: string;
|
||||
needsShell: boolean; // Windows .cmd/.bat/.ps1
|
||||
}
|
||||
|
||||
/**
|
||||
* Target adapter contract.
|
||||
*
|
||||
* Each target CLI implements this interface to handle:
|
||||
* - Binary detection (is the CLI installed?)
|
||||
* - Credential delivery (env vars vs config file writes)
|
||||
* - Argument building (target-specific flags)
|
||||
* - Process spawning (cross-platform execution)
|
||||
*/
|
||||
export interface TargetAdapter {
|
||||
readonly type: TargetType;
|
||||
readonly displayName: string;
|
||||
|
||||
/**
|
||||
* Resolve the target CLI executable on the current machine.
|
||||
* Return `null` when the binary is unavailable.
|
||||
*/
|
||||
detectBinary(): TargetBinaryInfo | null;
|
||||
|
||||
/**
|
||||
* Prepare credential delivery for the target.
|
||||
* Targets may write config files, mutate process state, or no-op.
|
||||
*
|
||||
* @throws Error when required credentials are missing or invalid.
|
||||
*/
|
||||
prepareCredentials(creds: TargetCredentials): Promise<void>;
|
||||
|
||||
/**
|
||||
* Build target-specific argument vector.
|
||||
* `userArgs` are the arguments after CCS profile/flag parsing.
|
||||
*/
|
||||
buildArgs(profile: string, userArgs: string[]): string[];
|
||||
|
||||
/**
|
||||
* Build environment variables for process spawn.
|
||||
* `profileType` allows targets to vary env behavior by CCS profile mode.
|
||||
*/
|
||||
buildEnv(creds: TargetCredentials, profileType: ProfileType): NodeJS.ProcessEnv;
|
||||
|
||||
/**
|
||||
* Spawn and hand over execution to the target CLI process.
|
||||
* Implementations are responsible for signal forwarding and exit propagation.
|
||||
*
|
||||
* @throws Error for unrecoverable launch failures (if not exiting directly).
|
||||
*/
|
||||
exec(
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
options?: { cwd?: string; binaryInfo?: TargetBinaryInfo }
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Report whether this target can run a given CCS profile type.
|
||||
*/
|
||||
supportsProfileType(profileType: ProfileType): boolean;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Target Registry
|
||||
*
|
||||
* Map-based registry for target adapters.
|
||||
* Adapters self-register at startup; lookup is O(1).
|
||||
*/
|
||||
|
||||
import { TargetAdapter, TargetType } from './target-adapter';
|
||||
|
||||
const adapters = new Map<TargetType, TargetAdapter>();
|
||||
|
||||
/**
|
||||
* Register a target adapter. Overwrites if already registered.
|
||||
*/
|
||||
export function registerTarget(adapter: TargetAdapter): void {
|
||||
adapters.set(adapter.type, adapter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a registered target adapter by type.
|
||||
* @throws Error if target type is not registered
|
||||
*/
|
||||
export function getTarget(type: TargetType): TargetAdapter {
|
||||
const adapter = adapters.get(type);
|
||||
if (!adapter) {
|
||||
const available = Array.from(adapters.keys()).join(', ');
|
||||
throw new Error(`Unknown target "${type}". Available: ${available}`);
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default target adapter ('claude').
|
||||
* @throws Error if claude adapter is not registered
|
||||
*/
|
||||
export function getDefaultTarget(): TargetAdapter {
|
||||
return getTarget('claude');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a target type is registered.
|
||||
*/
|
||||
export function hasTarget(type: TargetType): boolean {
|
||||
return adapters.has(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered target types.
|
||||
*/
|
||||
export function getRegisteredTargets(): TargetType[] {
|
||||
return Array.from(adapters.keys());
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Target Resolver
|
||||
*
|
||||
* Resolves which CLI target to use based on:
|
||||
* 1. --target flag (highest priority)
|
||||
* 2. Per-profile config
|
||||
* 3. argv[0] detection (busybox/symlink pattern)
|
||||
* 4. Default: 'claude'
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import { TargetType } from './target-adapter';
|
||||
|
||||
/**
|
||||
* Map of binary names to target types (busybox pattern).
|
||||
* When CCS is invoked as `ccsd`, it auto-selects the droid target.
|
||||
*/
|
||||
const ARGV0_TARGET_MAP: Record<string, TargetType> = {
|
||||
ccsd: 'droid',
|
||||
};
|
||||
|
||||
/**
|
||||
* Valid target types for --target flag validation.
|
||||
*/
|
||||
const VALID_TARGETS: ReadonlySet<string> = new Set<TargetType>(['claude', 'droid']);
|
||||
|
||||
interface ParsedTargetFlags {
|
||||
targetOverride?: TargetType;
|
||||
cleanedArgs: string[];
|
||||
}
|
||||
|
||||
function normalizeTargetValue(value: string): TargetType {
|
||||
const normalized = value.toLowerCase();
|
||||
if (VALID_TARGETS.has(normalized)) {
|
||||
return normalized as TargetType;
|
||||
}
|
||||
|
||||
const available = Array.from(VALID_TARGETS).join(', ');
|
||||
throw new Error(`Unknown target "${value}". Available: ${available}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and strip all --target flags from args.
|
||||
* Supports both "--target value" and "--target=value" forms.
|
||||
* For repeated flags, last one wins (common CLI precedence behavior).
|
||||
*/
|
||||
function parseTargetFlags(args: string[]): ParsedTargetFlags {
|
||||
const cleanedArgs: string[] = [];
|
||||
let targetOverride: TargetType | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
// POSIX option terminator: everything after `--` is positional.
|
||||
if (arg === '--') {
|
||||
cleanedArgs.push(...args.slice(i));
|
||||
break;
|
||||
}
|
||||
|
||||
if (arg === '--target') {
|
||||
const value = args[i + 1];
|
||||
if (!value || value.startsWith('-')) {
|
||||
throw new Error('--target requires a value (claude or droid)');
|
||||
}
|
||||
targetOverride = normalizeTargetValue(value);
|
||||
i += 1; // Skip value
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length).trim();
|
||||
if (!value) {
|
||||
throw new Error('--target requires a value (claude or droid)');
|
||||
}
|
||||
targetOverride = normalizeTargetValue(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
cleanedArgs.push(arg);
|
||||
}
|
||||
|
||||
return { targetOverride, cleanedArgs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve target type from multiple sources with priority ordering.
|
||||
*
|
||||
* @param args - CLI arguments (may contain --target flag)
|
||||
* @param profileConfig - Per-profile config with optional target field
|
||||
* @returns Resolved target type
|
||||
*/
|
||||
export function resolveTargetType(
|
||||
args: string[],
|
||||
profileConfig?: { target?: TargetType }
|
||||
): TargetType {
|
||||
const parsed = parseTargetFlags(args);
|
||||
|
||||
// 1. Check --target flag (highest priority)
|
||||
if (parsed.targetOverride) {
|
||||
return parsed.targetOverride;
|
||||
}
|
||||
|
||||
// 2. Check per-profile config
|
||||
if (profileConfig?.target) {
|
||||
return profileConfig.target;
|
||||
}
|
||||
|
||||
// 3. Check argv[0] (busybox pattern)
|
||||
// Strip common wrapper extensions for Windows shims/wrappers
|
||||
const rawBin = path.basename(process.argv[1] || process.argv0 || '');
|
||||
const binName = rawBin.replace(/\.(cmd|bat|ps1|exe)$/i, '');
|
||||
const argv0Target = ARGV0_TARGET_MAP[binName];
|
||||
if (argv0Target) {
|
||||
return argv0Target;
|
||||
}
|
||||
|
||||
// 4. Default
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip --target flag and its value from args array.
|
||||
* Returns new array without the flag (so it's not passed to target CLI).
|
||||
*/
|
||||
export function stripTargetFlag(args: string[]): string[] {
|
||||
return parseTargetFlags(args).cleanedArgs;
|
||||
}
|
||||
@@ -50,3 +50,6 @@ export type {
|
||||
// Utility types
|
||||
export { LogLevel } from './utils';
|
||||
export type { ErrorCode, ColorName, TerminalInfo, Result } from './utils';
|
||||
|
||||
// Profile routing types
|
||||
export type { ProfileType } from './profile';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Profile mode types used across routing and target adapters.
|
||||
*/
|
||||
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default';
|
||||
@@ -7,6 +7,7 @@
|
||||
import { spawn, spawnSync, ChildProcess } from 'child_process';
|
||||
import { ErrorManager } from './error-manager';
|
||||
import { getWebSearchHookEnv } from './websearch-manager';
|
||||
import { wireChildProcessSignals } from './signal-forwarder';
|
||||
|
||||
/**
|
||||
* Strip ANTHROPIC_* env vars from an environment object.
|
||||
@@ -62,7 +63,8 @@ export function execClaude(
|
||||
envVars: NodeJS.ProcessEnv | null = null
|
||||
): void {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||
const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli);
|
||||
const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli);
|
||||
|
||||
// Get WebSearch hook config env vars
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
@@ -98,7 +100,17 @@ export function execClaude(
|
||||
}
|
||||
|
||||
let child: ChildProcess;
|
||||
if (needsShell) {
|
||||
if (isPowerShellScript) {
|
||||
child = spawn(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env,
|
||||
}
|
||||
);
|
||||
} else if (needsShell) {
|
||||
// When shell needed: concatenate into string to avoid DEP0190 warning
|
||||
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
|
||||
child = spawn(cmdString, {
|
||||
@@ -116,13 +128,23 @@ export function execClaude(
|
||||
});
|
||||
}
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) process.kill(process.pid, signal as NodeJS.Signals);
|
||||
else process.exit(code || 0);
|
||||
});
|
||||
|
||||
child.on('error', async () => {
|
||||
await ErrorManager.showClaudeNotFound();
|
||||
wireChildProcessSignals(child, async (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EACCES') {
|
||||
console.error(`[X] Claude CLI is not executable: ${claudeCli}`);
|
||||
console.error(' Check file permissions and executable bit.');
|
||||
} else if (err.code === 'ENOENT') {
|
||||
if (isPowerShellScript) {
|
||||
console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).');
|
||||
console.error(' Ensure powershell.exe is available in PATH.');
|
||||
} else if (needsShell) {
|
||||
console.error('[X] Windows command shell not found for Claude wrapper launch.');
|
||||
console.error(' Ensure cmd.exe is available and accessible.');
|
||||
} else {
|
||||
await ErrorManager.showClaudeNotFound();
|
||||
}
|
||||
} else {
|
||||
console.error(`[X] Failed to start Claude CLI (${claudeCli}): ${err.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Signal Forwarder
|
||||
*
|
||||
* Shared utility for forwarding process signals to child processes
|
||||
* and cleaning up handlers on exit.
|
||||
*/
|
||||
import { ChildProcess } from 'child_process';
|
||||
|
||||
/**
|
||||
* Forward SIGINT, SIGTERM, SIGHUP to a child process.
|
||||
* Returns a cleanup function to remove the handlers.
|
||||
*/
|
||||
export function forwardSignals(child: ChildProcess): () => void {
|
||||
const forwardSigInt = () => {
|
||||
if (!child.killed) child.kill('SIGINT');
|
||||
};
|
||||
const forwardSigTerm = () => {
|
||||
if (!child.killed) child.kill('SIGTERM');
|
||||
};
|
||||
const forwardSighup = () => {
|
||||
if (!child.killed) child.kill('SIGHUP');
|
||||
};
|
||||
|
||||
process.on('SIGINT', forwardSigInt);
|
||||
process.on('SIGTERM', forwardSigTerm);
|
||||
process.on('SIGHUP', forwardSighup);
|
||||
|
||||
return () => {
|
||||
process.removeListener('SIGINT', forwardSigInt);
|
||||
process.removeListener('SIGTERM', forwardSigTerm);
|
||||
process.removeListener('SIGHUP', forwardSighup);
|
||||
};
|
||||
}
|
||||
|
||||
export type ChildProcessErrorHandler = (err: NodeJS.ErrnoException) => void | Promise<void>;
|
||||
export type ChildProcessExitHandler = (code: number | null, signal: NodeJS.Signals | null) => void;
|
||||
|
||||
function defaultExitHandler(code: number | null, signal: NodeJS.Signals | null): void {
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
else process.exit(code || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach shared signal-forwarding lifecycle handlers to a child process.
|
||||
* Ensures signal listeners are always cleaned up on child exit/error.
|
||||
*/
|
||||
export function wireChildProcessSignals(
|
||||
child: ChildProcess,
|
||||
onError: ChildProcessErrorHandler,
|
||||
onExit: ChildProcessExitHandler = defaultExitHandler
|
||||
): void {
|
||||
const cleanupSignalHandlers = forwardSignals(child);
|
||||
let settled = false;
|
||||
|
||||
const settle = (): boolean => {
|
||||
if (settled) return false;
|
||||
settled = true;
|
||||
cleanupSignalHandlers();
|
||||
return true;
|
||||
};
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (!settle()) return;
|
||||
onExit(code, signal);
|
||||
});
|
||||
|
||||
child.on('error', async (err: NodeJS.ErrnoException) => {
|
||||
if (!settle()) return;
|
||||
try {
|
||||
await onError(err);
|
||||
} catch (handlerErr) {
|
||||
const message = handlerErr instanceof Error ? handlerErr.message : String(handlerErr);
|
||||
console.error(`[X] Failed to handle child process error: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export interface RawUsageEntry {
|
||||
timestamp: string;
|
||||
projectPath: string;
|
||||
version?: string;
|
||||
target?: string;
|
||||
}
|
||||
|
||||
/** Internal structure matching JSONL assistant entries */
|
||||
@@ -40,6 +41,7 @@ interface JsonlAssistantEntry {
|
||||
timestamp: string;
|
||||
version?: string;
|
||||
cwd?: string;
|
||||
target?: string;
|
||||
message: {
|
||||
model: string;
|
||||
usage: {
|
||||
@@ -95,6 +97,10 @@ export function parseUsageEntry(line: string, projectPath: string): RawUsageEntr
|
||||
timestamp: assistant.timestamp || new Date().toISOString(),
|
||||
projectPath,
|
||||
version: assistant.version,
|
||||
target:
|
||||
typeof (entry as { target?: unknown }).target === 'string'
|
||||
? ((entry as { target?: string }).target as string)
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
// Malformed JSON - skip silently
|
||||
|
||||
@@ -371,6 +371,10 @@ export function aggregateSessionUsage(
|
||||
const sessionUsage: SessionUsage[] = [];
|
||||
|
||||
for (const [sessionId, sessionEntries] of bySession) {
|
||||
const orderedEntries = [...sessionEntries].sort((a, b) =>
|
||||
a.timestamp.localeCompare(b.timestamp)
|
||||
);
|
||||
|
||||
// Aggregate by model
|
||||
const modelMap = new Map<string, ModelAccumulator>();
|
||||
const versions = new Set<string>();
|
||||
@@ -380,8 +384,9 @@ export function aggregateSessionUsage(
|
||||
let totalCacheRead = 0;
|
||||
let lastActivity = '';
|
||||
let projectPath = '';
|
||||
let target: string | undefined;
|
||||
|
||||
for (const entry of sessionEntries) {
|
||||
for (const entry of orderedEntries) {
|
||||
const model = entry.model;
|
||||
const acc = modelMap.get(model) || {
|
||||
inputTokens: 0,
|
||||
@@ -415,6 +420,10 @@ export function aggregateSessionUsage(
|
||||
if (entry.projectPath) {
|
||||
projectPath = entry.projectPath;
|
||||
}
|
||||
|
||||
if (entry.target) {
|
||||
target = entry.target;
|
||||
}
|
||||
}
|
||||
|
||||
// Build model breakdowns
|
||||
@@ -450,6 +459,7 @@ export function aggregateSessionUsage(
|
||||
modelsUsed: Array.from(modelMap.keys()),
|
||||
modelBreakdowns,
|
||||
source,
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -549,6 +549,7 @@ export async function handleSessions(
|
||||
cost: Math.round(s.totalCost * 100) / 100,
|
||||
lastActivity: s.lastActivity,
|
||||
modelsUsed: s.modelsUsed,
|
||||
target: s.target || 'claude',
|
||||
}));
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -79,6 +79,8 @@ export interface SessionUsage {
|
||||
modelsUsed: string[];
|
||||
modelBreakdowns: ModelBreakdown[];
|
||||
source: string;
|
||||
/** Target CLI used for this session (default: 'claude') */
|
||||
target?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
registerSession,
|
||||
unregisterSession,
|
||||
getProxyStatus,
|
||||
} from '../../../src/cliproxy/session-tracker';
|
||||
|
||||
describe('session-tracker target metadata', () => {
|
||||
let tmpDir: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
const port = 28317;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-session-target-test-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns single target when all sessions share same target', () => {
|
||||
const s1 = registerSession(port, process.pid, undefined, undefined, 'droid');
|
||||
const s2 = registerSession(port, process.pid, undefined, undefined, 'droid');
|
||||
|
||||
const status = getProxyStatus(port);
|
||||
expect(status.running).toBe(true);
|
||||
expect(status.target).toBe('droid');
|
||||
expect(status.sessionCount).toBe(2);
|
||||
|
||||
unregisterSession(s1, port);
|
||||
unregisterSession(s2, port);
|
||||
});
|
||||
|
||||
it('returns mixed when active sessions use different targets', () => {
|
||||
const s1 = registerSession(port, process.pid, undefined, undefined, 'claude');
|
||||
const s2 = registerSession(port, process.pid, undefined, undefined, 'droid');
|
||||
|
||||
const status = getProxyStatus(port);
|
||||
expect(status.running).toBe(true);
|
||||
expect(status.target).toBe('mixed');
|
||||
expect(status.sessionCount).toBe(2);
|
||||
|
||||
unregisterSession(s1, port);
|
||||
unregisterSession(s2, port);
|
||||
});
|
||||
});
|
||||
@@ -237,6 +237,42 @@ describe('aggregateSessionUsage', () => {
|
||||
expect(result[1].sessionId).toBe('old-session');
|
||||
});
|
||||
|
||||
test('propagates target metadata into session usage', () => {
|
||||
const entries: RawUsageEntry[] = [
|
||||
createEntry({ sessionId: 'session-A', target: 'droid' }),
|
||||
createEntry({ sessionId: 'session-B' }),
|
||||
];
|
||||
|
||||
const result = aggregateSessionUsage(entries);
|
||||
const sessionA = result.find((s) => s.sessionId === 'session-A');
|
||||
const sessionB = result.find((s) => s.sessionId === 'session-B');
|
||||
|
||||
expect(sessionA?.target).toBe('droid');
|
||||
expect(sessionB?.target).toBeUndefined();
|
||||
});
|
||||
|
||||
test('uses latest timestamp target when a session has mixed targets', () => {
|
||||
const entries: RawUsageEntry[] = [
|
||||
createEntry({
|
||||
sessionId: 'session-A',
|
||||
timestamp: '2025-12-09T10:00:00.000Z',
|
||||
target: 'claude',
|
||||
}),
|
||||
createEntry({
|
||||
sessionId: 'session-A',
|
||||
timestamp: '2025-12-09T12:00:00.000Z',
|
||||
target: 'droid',
|
||||
}),
|
||||
createEntry({
|
||||
sessionId: 'session-A',
|
||||
timestamp: '2025-12-09T11:00:00.000Z',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = aggregateSessionUsage(entries);
|
||||
expect(result[0].target).toBe('droid');
|
||||
});
|
||||
|
||||
test('returns empty array for no entries', () => {
|
||||
const result = aggregateSessionUsage([]);
|
||||
expect(result.length).toBe(0);
|
||||
|
||||
@@ -147,6 +147,26 @@ describe('parseUsageEntry', () => {
|
||||
const result = parseUsageEntry(VALID_ASSISTANT_ENTRY, '/custom/project/path');
|
||||
expect(result!.projectPath).toBe('/custom/project/path');
|
||||
});
|
||||
|
||||
test('parses string target when present', () => {
|
||||
const withTarget = JSON.stringify({
|
||||
...JSON.parse(VALID_ASSISTANT_ENTRY),
|
||||
target: 'droid',
|
||||
});
|
||||
const result = parseUsageEntry(withTarget, '/test');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.target).toBe('droid');
|
||||
});
|
||||
|
||||
test('ignores non-string target values', () => {
|
||||
const withNumericTarget = JSON.stringify({
|
||||
...JSON.parse(VALID_ASSISTANT_ENTRY),
|
||||
target: 123,
|
||||
});
|
||||
const result = parseUsageEntry(withNumericTarget, '/test');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.target).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Integration-style test for Node argv alias behavior.
|
||||
*
|
||||
* This validates the runtime assumption used by target-resolver:
|
||||
* when invoked via a `ccsd` symlink, Node preserves the invoked
|
||||
* symlink path in process.argv[1].
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
function probeArgvPath(aliasBasename: string): string {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ccsd-alias-'));
|
||||
const scriptPath = path.join(tmpDir, 'probe.js');
|
||||
const aliasPath = path.join(tmpDir, aliasBasename);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(scriptPath, 'console.log(process.argv[1]);\n', { encoding: 'utf8' });
|
||||
fs.symlinkSync(scriptPath, aliasPath);
|
||||
|
||||
const result = spawnSync('node', [aliasPath], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
return result.stdout.trim();
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function probeArgvPathDirect(filename: string): string {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ccsd-direct-'));
|
||||
const scriptPath = path.join(tmpDir, filename);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(scriptPath, 'console.log(process.argv[1]);\n', { encoding: 'utf8' });
|
||||
|
||||
const result = spawnSync('node', [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
return result.stdout.trim();
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('ccsd alias integration', () => {
|
||||
it('should preserve ccsd symlink basename in argv[1] under node', () => {
|
||||
if (process.platform === 'win32') {
|
||||
// Windows symlink creation requires elevated privileges/Developer Mode.
|
||||
return;
|
||||
}
|
||||
|
||||
const argvPath = probeArgvPath('ccsd');
|
||||
expect(path.basename(argvPath)).toBe('ccsd');
|
||||
});
|
||||
|
||||
it('should preserve extension-style alias basenames for wrapper compatibility', () => {
|
||||
const cmdArgvPath = probeArgvPathDirect('ccsd.cmd');
|
||||
const ps1ArgvPath = probeArgvPathDirect('ccsd.ps1');
|
||||
|
||||
expect(path.basename(cmdArgvPath)).toBe('ccsd.cmd');
|
||||
expect(path.basename(ps1ArgvPath)).toBe('ccsd.ps1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* Unit tests for Droid config manager
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import {
|
||||
upsertCcsModel,
|
||||
removeCcsModel,
|
||||
listCcsModels,
|
||||
pruneOrphanedModels,
|
||||
} from '../../../src/targets/droid-config-manager';
|
||||
|
||||
describe('droid-config-manager', () => {
|
||||
let tmpDir: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-test-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('upsertCcsModel', () => {
|
||||
it('should create settings.json with customModels', async () => {
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy-key',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
|
||||
expect(fs.existsSync(settingsPath)).toBe(true);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
expect(settings.customModels).toHaveLength(1);
|
||||
expect(settings.customModels[0].displayName).toBe('CCS gemini');
|
||||
expect(settings.customModels[0].baseUrl).toBe('http://localhost:8317');
|
||||
});
|
||||
|
||||
it('should update existing entry on second upsert', async () => {
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'key-1',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8318',
|
||||
apiKey: 'key-2',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
expect(settings.customModels).toHaveLength(1);
|
||||
expect(settings.customModels[0].apiKey).toBe('key-2');
|
||||
expect(settings.customModels[0].baseUrl).toBe('http://localhost:8318');
|
||||
});
|
||||
|
||||
it('should preserve user entries', async () => {
|
||||
// Create existing settings with user's own custom model
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{
|
||||
model: 'gpt-4o',
|
||||
displayName: 'My GPT',
|
||||
baseUrl: 'https://api.openai.com',
|
||||
apiKey: 'sk-xxx',
|
||||
provider: 'openai',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
const settings = JSON.parse(
|
||||
fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8')
|
||||
);
|
||||
expect(settings.customModels).toHaveLength(2);
|
||||
expect(settings.customModels[0].displayName).toBe('My GPT');
|
||||
expect(settings.customModels[1].displayName).toBe('CCS gemini');
|
||||
});
|
||||
|
||||
it('should preserve user entries with unknown provider strings', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{
|
||||
model: 'user-model',
|
||||
displayName: 'My Custom Provider',
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: 'user-key',
|
||||
provider: 'custom-provider',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
const settings = JSON.parse(
|
||||
fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8')
|
||||
);
|
||||
expect(settings.customModels).toHaveLength(2);
|
||||
expect(settings.customModels[0].provider).toBe('custom-provider');
|
||||
expect(settings.customModels[1].displayName).toBe('CCS gemini');
|
||||
});
|
||||
|
||||
it('should write with restricted permissions', async () => {
|
||||
await upsertCcsModel('test', {
|
||||
model: 'test-model',
|
||||
displayName: 'CCS test',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'secret',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
|
||||
const stat = fs.statSync(settingsPath);
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const otherPerms = stat.mode & 0o077;
|
||||
expect(otherPerms).toBe(0);
|
||||
});
|
||||
|
||||
it('should reject symlinked temp file path', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(factoryDir, 'settings.json'), JSON.stringify({ customModels: [] }));
|
||||
fs.symlinkSync('/tmp', path.join(factoryDir, 'settings.json.tmp'));
|
||||
|
||||
await expect(
|
||||
upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy-key',
|
||||
provider: 'anthropic',
|
||||
})
|
||||
).rejects.toThrow(/settings\.json\.tmp is a symlink/);
|
||||
});
|
||||
|
||||
it('should update legacy ccs- alias entry on upsert', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'ccs-gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'old-key',
|
||||
provider: 'anthropic',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8318',
|
||||
apiKey: 'new-key',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
const settings = JSON.parse(
|
||||
fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8')
|
||||
);
|
||||
expect(settings.customModels).toHaveLength(1);
|
||||
expect(settings.customModels[0].displayName).toBe('CCS gemini');
|
||||
expect(settings.customModels[0].apiKey).toBe('new-key');
|
||||
expect(settings.customModels[0].baseUrl).toBe('http://localhost:8318');
|
||||
});
|
||||
|
||||
it('should reject symlinked settings file on write', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
|
||||
const realSettings = path.join(factoryDir, 'real-settings.json');
|
||||
fs.writeFileSync(realSettings, JSON.stringify({ customModels: [] }));
|
||||
fs.symlinkSync(realSettings, path.join(factoryDir, 'settings.json'));
|
||||
|
||||
await expect(
|
||||
upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy-key',
|
||||
provider: 'anthropic',
|
||||
})
|
||||
).rejects.toThrow(/settings\.json is a symlink/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeCcsModel', () => {
|
||||
it('should remove a CCS entry', async () => {
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'claude-opus-4-6',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
await removeCcsModel('gemini');
|
||||
|
||||
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
expect(settings.customModels).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not remove user entries', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{ model: 'gpt-4o', displayName: 'My GPT', baseUrl: 'x', apiKey: 'y', provider: 'openai' },
|
||||
{ model: 'opus', displayName: 'CCS gemini', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await removeCcsModel('gemini');
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8'));
|
||||
expect(settings.customModels).toHaveLength(1);
|
||||
expect(settings.customModels[0].displayName).toBe('My GPT');
|
||||
});
|
||||
|
||||
it('should remove legacy ccs- alias entries', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{ model: 'opus', displayName: 'ccs-gemini', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
{ model: 'gpt-4o', displayName: 'My GPT', baseUrl: 'x', apiKey: 'y', provider: 'openai' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await removeCcsModel('gemini');
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8'));
|
||||
expect(settings.customModels).toHaveLength(1);
|
||||
expect(settings.customModels[0].displayName).toBe('My GPT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listCcsModels', () => {
|
||||
it('should list only CCS entries', async () => {
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'opus',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
await upsertCcsModel('codex', {
|
||||
model: 'sonnet',
|
||||
displayName: 'CCS codex',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(2);
|
||||
expect(models.has('gemini')).toBe(true);
|
||||
expect(models.has('codex')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty map when no settings file', async () => {
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should normalize legacy object-map customModels', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: {
|
||||
gemini: {
|
||||
model: 'opus',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy',
|
||||
provider: 'anthropic',
|
||||
},
|
||||
invalid: {
|
||||
model: 'x',
|
||||
baseUrl: 'x',
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(1);
|
||||
expect(models.has('gemini')).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore malformed customModels entries', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [null, 123, 'bad', { displayName: 'CCS ok', model: 'x', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }],
|
||||
})
|
||||
);
|
||||
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(1);
|
||||
expect(models.has('ok')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject symlinked settings file on read', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
const target = path.join(factoryDir, 'real-settings.json');
|
||||
fs.writeFileSync(target, JSON.stringify({ customModels: [] }));
|
||||
fs.symlinkSync(target, path.join(factoryDir, 'settings.json'));
|
||||
|
||||
await expect(listCcsModels()).rejects.toThrow(/settings\.json is a symlink/);
|
||||
});
|
||||
|
||||
it('should include legacy ccs- alias entries', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{
|
||||
model: 'opus',
|
||||
displayName: 'ccs-gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy',
|
||||
provider: 'anthropic',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(1);
|
||||
expect(models.has('gemini')).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore malformed managed display names', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{ model: 'x', displayName: 'CCS ', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
{ model: 'x', displayName: 'ccs-', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
{ model: 'x', displayName: 'CCS ok', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(1);
|
||||
expect(models.has('ok')).toBe(true);
|
||||
});
|
||||
|
||||
it('should recover from corrupted JSON by backing up and returning empty models', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
const settingsPath = path.join(factoryDir, 'settings.json');
|
||||
|
||||
fs.writeFileSync(settingsPath, '{"customModels":[', 'utf8');
|
||||
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(0);
|
||||
expect(fs.existsSync(`${settingsPath}.bak`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneOrphanedModels', () => {
|
||||
it('should remove orphaned CCS entries', async () => {
|
||||
await upsertCcsModel('gemini', {
|
||||
model: 'opus',
|
||||
displayName: 'CCS gemini',
|
||||
baseUrl: 'x',
|
||||
apiKey: 'y',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
await upsertCcsModel('codex', {
|
||||
model: 'sonnet',
|
||||
displayName: 'CCS codex',
|
||||
baseUrl: 'x',
|
||||
apiKey: 'y',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
|
||||
// Only gemini is active — codex should be pruned
|
||||
const removed = await pruneOrphanedModels(['gemini']);
|
||||
expect(removed).toBe(1);
|
||||
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(1);
|
||||
expect(models.has('gemini')).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve user entries during prune', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{ model: 'gpt-4o', displayName: 'My GPT', baseUrl: 'x', apiKey: 'y', provider: 'openai' },
|
||||
{ model: 'opus', displayName: 'CCS old-profile', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const removed = await pruneOrphanedModels([]);
|
||||
expect(removed).toBe(1);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8'));
|
||||
expect(settings.customModels).toHaveLength(1);
|
||||
expect(settings.customModels[0].displayName).toBe('My GPT');
|
||||
});
|
||||
|
||||
it('should prune orphaned legacy ccs- alias entries', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{ model: 'opus', displayName: 'ccs-gemini', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
{ model: 'sonnet', displayName: 'ccs-codex', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const removed = await pruneOrphanedModels(['gemini']);
|
||||
expect(removed).toBe(1);
|
||||
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(1);
|
||||
expect(models.has('gemini')).toBe(true);
|
||||
});
|
||||
|
||||
it('should prune malformed managed entries while preserving user models', async () => {
|
||||
const factoryDir = path.join(tmpDir, '.factory');
|
||||
fs.mkdirSync(factoryDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(factoryDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
customModels: [
|
||||
{ model: 'x', displayName: 'CCS ', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
{ model: 'x', displayName: 'ccs-', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' },
|
||||
{ model: 'gpt-4o', displayName: 'My GPT', baseUrl: 'x', apiKey: 'y', provider: 'openai' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const removed = await pruneOrphanedModels([]);
|
||||
expect(removed).toBe(2);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8'));
|
||||
expect(settings.customModels).toHaveLength(1);
|
||||
expect(settings.customModels[0].displayName).toBe('My GPT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent writes', () => {
|
||||
it(
|
||||
'should handle concurrent upserts without data loss',
|
||||
async () => {
|
||||
const profiles = Array.from({ length: 10 }, (_, i) => `profile-${i}`);
|
||||
|
||||
await Promise.all(
|
||||
profiles.map((p) =>
|
||||
upsertCcsModel(p, {
|
||||
model: 'test-model',
|
||||
displayName: `CCS ${p}`,
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'key',
|
||||
provider: 'anthropic',
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const models = await listCcsModels();
|
||||
expect(models.size).toBe(10);
|
||||
|
||||
for (const p of profiles) {
|
||||
expect(models.has(p)).toBe(true);
|
||||
}
|
||||
},
|
||||
15000
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Unit tests for Droid detector edge cases
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { detectDroidCli, checkDroidVersion } from '../../../src/targets/droid-detector';
|
||||
|
||||
describe('droid-detector', () => {
|
||||
let tmpDir: string;
|
||||
let originalPath: string | undefined;
|
||||
let originalDroidPath: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-detector-test-'));
|
||||
originalPath = process.env.PATH;
|
||||
originalDroidPath = process.env.CCS_DROID_PATH;
|
||||
process.env.PATH = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPath !== undefined) process.env.PATH = originalPath;
|
||||
else delete process.env.PATH;
|
||||
|
||||
if (originalDroidPath !== undefined) process.env.CCS_DROID_PATH = originalDroidPath;
|
||||
else delete process.env.CCS_DROID_PATH;
|
||||
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should prefer CCS_DROID_PATH when it points to a file', () => {
|
||||
const fakeDroid = path.join(tmpDir, 'droid');
|
||||
fs.writeFileSync(fakeDroid, '#!/bin/sh\necho droid\n');
|
||||
process.env.CCS_DROID_PATH = fakeDroid;
|
||||
|
||||
expect(detectDroidCli()).toBe(fakeDroid);
|
||||
});
|
||||
|
||||
it('should fall back (null) when CCS_DROID_PATH points to directory', () => {
|
||||
process.env.CCS_DROID_PATH = tmpDir;
|
||||
expect(detectDroidCli()).toBeNull();
|
||||
});
|
||||
|
||||
it('should fall back (null) when CCS_DROID_PATH does not exist', () => {
|
||||
process.env.CCS_DROID_PATH = path.join(tmpDir, 'missing-droid');
|
||||
expect(detectDroidCli()).toBeNull();
|
||||
});
|
||||
|
||||
it('checkDroidVersion should be non-throwing for invalid binaries', () => {
|
||||
expect(() => checkDroidVersion(path.join(tmpDir, 'missing-droid'))).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Unit tests for target registry and adapters
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
registerTarget,
|
||||
getTarget,
|
||||
getDefaultTarget,
|
||||
hasTarget,
|
||||
getRegisteredTargets,
|
||||
ClaudeAdapter,
|
||||
DroidAdapter,
|
||||
} from '../../../src/targets';
|
||||
|
||||
describe('target-registry', () => {
|
||||
beforeEach(() => {
|
||||
// Re-register adapters (registry is module-scoped singleton)
|
||||
registerTarget(new ClaudeAdapter());
|
||||
registerTarget(new DroidAdapter());
|
||||
});
|
||||
|
||||
it('should register and retrieve claude adapter', () => {
|
||||
const adapter = getTarget('claude');
|
||||
expect(adapter.type).toBe('claude');
|
||||
expect(adapter.displayName).toBe('Claude Code');
|
||||
});
|
||||
|
||||
it('should register and retrieve droid adapter', () => {
|
||||
const adapter = getTarget('droid');
|
||||
expect(adapter.type).toBe('droid');
|
||||
expect(adapter.displayName).toBe('Factory Droid');
|
||||
});
|
||||
|
||||
it('should return claude as default target', () => {
|
||||
const adapter = getDefaultTarget();
|
||||
expect(adapter.type).toBe('claude');
|
||||
});
|
||||
|
||||
it('should throw for unknown target', () => {
|
||||
expect(() => getTarget('unknown' as never)).toThrow(/Unknown target "unknown"/);
|
||||
});
|
||||
|
||||
it('should check target existence', () => {
|
||||
expect(hasTarget('claude')).toBe(true);
|
||||
expect(hasTarget('droid')).toBe(true);
|
||||
expect(hasTarget('unknown' as never)).toBe(false);
|
||||
});
|
||||
|
||||
it('should list registered targets', () => {
|
||||
const targets = getRegisteredTargets();
|
||||
expect(targets).toContain('claude');
|
||||
expect(targets).toContain('droid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClaudeAdapter', () => {
|
||||
const adapter = new ClaudeAdapter();
|
||||
|
||||
it('should have correct type and displayName', () => {
|
||||
expect(adapter.type).toBe('claude');
|
||||
expect(adapter.displayName).toBe('Claude Code');
|
||||
});
|
||||
|
||||
it('should support all profile types', () => {
|
||||
expect(adapter.supportsProfileType('account')).toBe(true);
|
||||
expect(adapter.supportsProfileType('settings')).toBe(true);
|
||||
expect(adapter.supportsProfileType('cliproxy')).toBe(true);
|
||||
expect(adapter.supportsProfileType('default')).toBe(true);
|
||||
expect(adapter.supportsProfileType('copilot')).toBe(true);
|
||||
});
|
||||
|
||||
it('should build env with credentials', () => {
|
||||
const env = adapter.buildEnv(
|
||||
{
|
||||
profile: 'gemini',
|
||||
baseUrl: 'https://api.example.com',
|
||||
apiKey: 'test-key',
|
||||
model: 'claude-opus-4-6',
|
||||
},
|
||||
'settings'
|
||||
);
|
||||
|
||||
expect(env['ANTHROPIC_BASE_URL']).toBe('https://api.example.com');
|
||||
expect(env['ANTHROPIC_AUTH_TOKEN']).toBe('test-key');
|
||||
expect(env['ANTHROPIC_MODEL']).toBe('claude-opus-4-6');
|
||||
});
|
||||
|
||||
it('should pass through args unchanged', () => {
|
||||
const args = adapter.buildArgs('gemini', ['-p', 'hello', '--verbose']);
|
||||
expect(args).toEqual(['-p', 'hello', '--verbose']);
|
||||
});
|
||||
|
||||
it('prepareCredentials should be no-op', async () => {
|
||||
// Should not throw
|
||||
await adapter.prepareCredentials({
|
||||
profile: 'test',
|
||||
baseUrl: 'x',
|
||||
apiKey: 'y',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DroidAdapter', () => {
|
||||
const adapter = new DroidAdapter();
|
||||
|
||||
it('should have correct type and displayName', () => {
|
||||
expect(adapter.type).toBe('droid');
|
||||
expect(adapter.displayName).toBe('Factory Droid');
|
||||
});
|
||||
|
||||
it('should NOT support account profile type', () => {
|
||||
expect(adapter.supportsProfileType('account')).toBe(false);
|
||||
});
|
||||
|
||||
it('should support settings and default profile types', () => {
|
||||
expect(adapter.supportsProfileType('settings')).toBe(true);
|
||||
expect(adapter.supportsProfileType('default')).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT support cliproxy and copilot profile types', () => {
|
||||
expect(adapter.supportsProfileType('cliproxy')).toBe(false);
|
||||
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 build minimal env (no ANTHROPIC_ vars)', () => {
|
||||
const env = adapter.buildEnv(
|
||||
{
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy',
|
||||
},
|
||||
'cliproxy'
|
||||
);
|
||||
|
||||
// Droid uses config file, not env vars
|
||||
expect(env['ANTHROPIC_BASE_URL']).toBeUndefined();
|
||||
expect(env['ANTHROPIC_AUTH_TOKEN']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prepareCredentials should reject missing required credentials', async () => {
|
||||
await expect(
|
||||
adapter.prepareCredentials({
|
||||
profile: 'gemini',
|
||||
baseUrl: '',
|
||||
apiKey: 'dummy',
|
||||
})
|
||||
).rejects.toThrow(/ANTHROPIC_BASE_URL/);
|
||||
|
||||
await expect(
|
||||
adapter.prepareCredentials({
|
||||
profile: 'gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: '',
|
||||
})
|
||||
).rejects.toThrow(/ANTHROPIC_AUTH_TOKEN/);
|
||||
});
|
||||
|
||||
it('prepareCredentials should persist valid credentials', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-adapter-test-'));
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpDir;
|
||||
|
||||
try {
|
||||
await adapter.prepareCredentials({
|
||||
profile: 'gemini',
|
||||
baseUrl: 'http://localhost:8317',
|
||||
apiKey: 'dummy-key',
|
||||
model: 'claude-opus-4-6',
|
||||
});
|
||||
|
||||
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
|
||||
expect(fs.existsSync(settingsPath)).toBe(true);
|
||||
} finally {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Unit tests for target resolver
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { resolveTargetType, stripTargetFlag } from '../../../src/targets/target-resolver';
|
||||
|
||||
describe('resolveTargetType', () => {
|
||||
const originalArgv = process.argv;
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
});
|
||||
|
||||
it('should return claude as default', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType([])).toBe('claude');
|
||||
});
|
||||
|
||||
it('should detect --target flag', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType(['--target', 'droid'])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should detect --target claude', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType(['--target', 'claude'])).toBe('claude');
|
||||
});
|
||||
|
||||
it('should use per-profile config when no flag', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType([], { target: 'droid' })).toBe('droid');
|
||||
});
|
||||
|
||||
it('should prioritize --target flag over profile config', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType(['--target', 'claude'], { target: 'droid' })).toBe('claude');
|
||||
});
|
||||
|
||||
it('should detect ccsd argv[0] (busybox pattern)', () => {
|
||||
process.argv = ['node', 'ccsd'];
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should strip .cmd extension on Windows argv[0]', () => {
|
||||
process.argv = ['node', 'ccsd.cmd'];
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should strip .bat extension on Windows argv[0]', () => {
|
||||
process.argv = ['node', 'ccsd.bat'];
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should strip .ps1 extension on Windows argv[0]', () => {
|
||||
process.argv = ['node', 'ccsd.ps1'];
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should strip .exe extension on Windows argv[0]', () => {
|
||||
process.argv = ['node', 'ccsd.exe'];
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should handle full path argv[0]', () => {
|
||||
process.argv = ['node', '/usr/local/bin/ccsd'];
|
||||
expect(resolveTargetType([])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should prioritize --target over argv[0]', () => {
|
||||
process.argv = ['node', 'ccsd'];
|
||||
expect(resolveTargetType(['--target', 'claude'])).toBe('claude');
|
||||
});
|
||||
|
||||
it('should prioritize profile config over argv[0]', () => {
|
||||
process.argv = ['node', 'ccsd'];
|
||||
expect(resolveTargetType([], { target: 'claude' })).toBe('claude');
|
||||
});
|
||||
|
||||
it('should throw for invalid --target value', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(() => resolveTargetType(['--target', 'invalid'])).toThrow(/Unknown target "invalid"/);
|
||||
});
|
||||
|
||||
it('should support --target=<value> form', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType(['--target=droid'])).toBe('droid');
|
||||
});
|
||||
|
||||
it('should throw when --target is missing value', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(() => resolveTargetType(['--target'])).toThrow(/--target requires a value/);
|
||||
});
|
||||
|
||||
it('should throw when --target value is another flag', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(() => resolveTargetType(['--target', '--help'])).toThrow(/--target requires a value/);
|
||||
});
|
||||
|
||||
it('should use last --target flag when repeated', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType(['--target', 'droid', '--target=claude'])).toBe('claude');
|
||||
});
|
||||
|
||||
it('should ignore --target after option terminator', () => {
|
||||
process.argv = ['node', 'ccs'];
|
||||
expect(resolveTargetType(['--', '--target', 'droid'])).toBe('claude');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripTargetFlag', () => {
|
||||
it('should remove --target and its value', () => {
|
||||
expect(stripTargetFlag(['gemini', '--target', 'droid'])).toEqual(['gemini']);
|
||||
});
|
||||
|
||||
it('should handle --target at start', () => {
|
||||
expect(stripTargetFlag(['--target', 'droid', 'gemini'])).toEqual(['gemini']);
|
||||
});
|
||||
|
||||
it('should return args unchanged if no --target', () => {
|
||||
const args = ['gemini', '-p', 'hello'];
|
||||
expect(stripTargetFlag(args)).toEqual(['gemini', '-p', 'hello']);
|
||||
});
|
||||
|
||||
it('should remove --target=<value> form', () => {
|
||||
expect(stripTargetFlag(['gemini', '--target=droid', '--verbose'])).toEqual([
|
||||
'gemini',
|
||||
'--verbose',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should remove repeated --target flags', () => {
|
||||
expect(stripTargetFlag(['--target', 'droid', 'gemini', '--target=claude', '--verbose'])).toEqual(
|
||||
['gemini', '--verbose']
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when --target has no value', () => {
|
||||
expect(() => stripTargetFlag(['gemini', '--target'])).toThrow(/--target requires a value/);
|
||||
});
|
||||
|
||||
it('should throw when --target value is another flag', () => {
|
||||
expect(() => stripTargetFlag(['gemini', '--target', '--help'])).toThrow(
|
||||
/--target requires a value/
|
||||
);
|
||||
});
|
||||
|
||||
it('should not modify the original array', () => {
|
||||
const args = ['--target', 'droid', 'gemini'];
|
||||
stripTargetFlag(args);
|
||||
expect(args).toEqual(['--target', 'droid', 'gemini']);
|
||||
});
|
||||
|
||||
it('should preserve args after option terminator', () => {
|
||||
expect(stripTargetFlag(['glm', '--', '--target', 'droid', '-p'])).toEqual([
|
||||
'glm',
|
||||
'--',
|
||||
'--target',
|
||||
'droid',
|
||||
'-p',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, jest } from 'bun:test';
|
||||
import { EventEmitter } from 'events';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
import { forwardSignals, wireChildProcessSignals } from '../../../src/utils/signal-forwarder';
|
||||
|
||||
type MockChildProcess = EventEmitter & {
|
||||
killed: boolean;
|
||||
kill: ChildProcess['kill'];
|
||||
};
|
||||
|
||||
function createMockChildProcess(): ChildProcess {
|
||||
const child = new EventEmitter() as MockChildProcess;
|
||||
child.killed = false;
|
||||
child.kill = jest.fn(() => true) as ChildProcess['kill'];
|
||||
return child as ChildProcess;
|
||||
}
|
||||
|
||||
function getSignalListenerCounts(): Record<'SIGINT' | 'SIGTERM' | 'SIGHUP', number> {
|
||||
return {
|
||||
SIGINT: process.listenerCount('SIGINT'),
|
||||
SIGTERM: process.listenerCount('SIGTERM'),
|
||||
SIGHUP: process.listenerCount('SIGHUP'),
|
||||
};
|
||||
}
|
||||
|
||||
describe('signal-forwarder', () => {
|
||||
it('forwardSignals should register and cleanup listeners', () => {
|
||||
const child = createMockChildProcess();
|
||||
const before = getSignalListenerCounts();
|
||||
|
||||
const cleanup = forwardSignals(child);
|
||||
|
||||
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT + 1);
|
||||
expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM + 1);
|
||||
expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP + 1);
|
||||
|
||||
cleanup();
|
||||
|
||||
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT);
|
||||
expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM);
|
||||
expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP);
|
||||
});
|
||||
|
||||
it('wireChildProcessSignals should use default exit behavior for exit code', () => {
|
||||
const child = createMockChildProcess();
|
||||
const before = getSignalListenerCounts();
|
||||
|
||||
const exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined as never) as typeof process.exit);
|
||||
const killSpy = jest
|
||||
.spyOn(process, 'kill')
|
||||
.mockImplementation((() => true) as typeof process.kill);
|
||||
|
||||
try {
|
||||
wireChildProcessSignals(child, () => {});
|
||||
child.emit('exit', 7, null);
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(7);
|
||||
expect(killSpy).not.toHaveBeenCalled();
|
||||
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT);
|
||||
expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM);
|
||||
expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
killSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('wireChildProcessSignals should use default exit behavior for signal', () => {
|
||||
const child = createMockChildProcess();
|
||||
const before = getSignalListenerCounts();
|
||||
|
||||
const exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined as never) as typeof process.exit);
|
||||
const killSpy = jest
|
||||
.spyOn(process, 'kill')
|
||||
.mockImplementation((() => true) as typeof process.kill);
|
||||
|
||||
try {
|
||||
wireChildProcessSignals(child, () => {});
|
||||
child.emit('exit', null, 'SIGTERM');
|
||||
|
||||
expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGTERM');
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT);
|
||||
expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM);
|
||||
expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
killSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('wireChildProcessSignals should invoke onError and cleanup listeners', async () => {
|
||||
const child = createMockChildProcess();
|
||||
const before = getSignalListenerCounts();
|
||||
const onError = jest.fn(async () => {});
|
||||
const err = Object.assign(new Error('spawn failed'), { code: 'ENOENT' }) as NodeJS.ErrnoException;
|
||||
|
||||
wireChildProcessSignals(child, onError);
|
||||
child.emit('error', err);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(err);
|
||||
expect(process.listenerCount('SIGINT')).toBe(before.SIGINT);
|
||||
expect(process.listenerCount('SIGTERM')).toBe(before.SIGTERM);
|
||||
expect(process.listenerCount('SIGHUP')).toBe(before.SIGHUP);
|
||||
});
|
||||
|
||||
it('wireChildProcessSignals should run only one terminal callback when error is followed by exit', async () => {
|
||||
const child = createMockChildProcess();
|
||||
const onError = jest.fn(async () => {});
|
||||
const onExit = jest.fn();
|
||||
const err = Object.assign(new Error('spawn failed'), { code: 'ENOENT' }) as NodeJS.ErrnoException;
|
||||
|
||||
wireChildProcessSignals(child, onError, onExit);
|
||||
child.emit('error', err);
|
||||
child.emit('exit', 1, null);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(onExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wireChildProcessSignals should exit with code 1 when onError throws', async () => {
|
||||
const child = createMockChildProcess();
|
||||
const onError = jest.fn(async () => {
|
||||
throw new Error('handler exploded');
|
||||
});
|
||||
const exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined as never) as typeof process.exit);
|
||||
|
||||
try {
|
||||
const err = Object.assign(new Error('spawn failed'), { code: 'ENOENT' }) as NodeJS.ErrnoException;
|
||||
wireChildProcessSignals(child, onError);
|
||||
child.emit('error', err);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -133,9 +133,16 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
|
||||
className="flex items-center justify-between text-xs p-1.5 rounded bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="font-medium truncate" title={session.projectPath}>
|
||||
{getProjectDisplayName(session.projectPath)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium truncate" title={session.projectPath}>
|
||||
{getProjectDisplayName(session.projectPath)}
|
||||
</span>
|
||||
{(session.target ?? 'claude') !== 'claude' && (
|
||||
<span className="shrink-0 px-1 py-0 text-[9px] font-medium rounded bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 uppercase">
|
||||
{session.target}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(session.lastActivity), { addSuffix: true })}
|
||||
</span>
|
||||
|
||||
@@ -100,6 +100,8 @@ export interface Session {
|
||||
cost: number;
|
||||
lastActivity: string;
|
||||
modelsUsed: string[];
|
||||
/** Target CLI used (default: 'claude') */
|
||||
target?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedSessions {
|
||||
|
||||
Reference in New Issue
Block a user