mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 18:16:08 +00:00
- add README guidance for ccsd argv[0] auto-target behavior - document CCS_DROID_ALIASES runtime extension in architecture docs
625 lines
16 KiB
Markdown
625 lines
16 KiB
Markdown
# 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
|
|
# Built-in alias: ccsd
|
|
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.
|
|
|
|
Additional alias names can be configured at runtime via `CCS_DROID_ALIASES` (comma-separated). Example: `CCS_DROID_ALIASES=ccs-droid,mydroid`.
|
|
|
|
---
|
|
|
|
## 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
|