Files
ccs/docs/system-architecture/target-adapters.md
T
Tam Nhu Tran 91edc9565b docs(targets): document built-in and env droid aliases
- add README guidance for ccsd argv[0] auto-target behavior

- document CCS_DROID_ALIASES runtime extension in architecture docs
2026-02-17 20:53:03 +07:00

16 KiB

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:

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

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

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

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

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

# 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

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

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

# 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

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

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

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

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

// src/targets/target-adapter.ts

export type TargetType = 'claude' | 'droid' | 'myai';

3. Register in ccs.ts

registerTarget(new MyAiAdapter());

4. Update Documentation

  • Add to Codebase Summary
  • Update Code Standards adapter examples
  • Document CLI-specific behavior

Cross-Platform Considerations

Windows Shell Detection

Both adapters check for shell-requiring binaries:

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:

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:

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

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

# Test Claude adapter
ccs --target claude help

# Test Droid adapter (if installed)
ccs --target droid help

# Test argv[0] detection
ccsd help