feat(targets): add multi-target CLI adapter system (Droid support)

Implement target adapter pattern enabling CCS CLI to support multiple backend targets
(Claude, Droid) via pluggable adapters. Core additions:

- TargetAdapter interface for pluggable target implementations
- ClaudeAdapter and DroidAdapter concrete implementations
- Target registry (singleton Map-based storage)
- Target resolver with precedence: --target flag > per-profile config > busybox detection
- Droid config manager with atomic writes and file locking to ~/.factory/settings.json
- Droid binary detector to validate runtime environment
- Adapter dispatch integrated into ccs.ts main execution flow
- ccsd busybox alias for seamless Droid invocation
- --target flag documentation in help
- Session tracking enriched with target metadata
- Dashboard target badge for visual identification

Testing:
- 43 unit tests covering resolver, registry, config manager, and adapters
- Full coverage of target detection logic and edge cases

Documentation:
- Refactored system-architecture.md into modular docs/system-architecture/ subdirectory
- Updated code-standards.md with target adapter guidelines
- Updated codebase-summary.md with architecture overview
- Updated maintainability baseline (33.8% → 35.2%)

This establishes extensible foundation for multi-target support without breaking
existing Claude workflows. Droid adapter is production-ready but defaults to Claude
for backward compatibility.
This commit is contained in:
Tam Nhu Tran committed 2026-02-16 10:49:09 +07:00
1 parent 8ab78f039c
commit 7d7054e2c0
27 files changed
+3157 -766

No files matched your search

+98 -5
View File
@@ -38,6 +38,16 @@ 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,
type TargetCredentials,
} from './targets';
import { resolveTargetType, stripTargetFlag } from './targets/target-resolver';
// Version and Update check utilities
import { getVersion } from './utils/version';
import {
@@ -264,6 +274,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
@@ -599,15 +613,35 @@ async function main(): Promise<void> {
console.log('');
}
// Detect profile
const { profile, remainingArgs } = detectProfile(args);
// Detect profile (strip --target from args before profile detection)
const cleanArgs = stripTargetFlag(args);
const { profile, remainingArgs } = detectProfile(cleanArgs);
// Detect Claude CLI first (needed for all paths)
const claudeCli = detectClaudeCli();
if (!claudeCli) {
// Resolve target CLI (--target flag > per-profile config > argv[0] > 'claude')
const resolvedTarget = resolveTargetType(args);
// Detect Claude CLI (needed for claude target and CLIProxy flows)
const claudeCliRaw = detectClaudeCli();
if (resolvedTarget === 'claude' && !claudeCliRaw) {
await ErrorManager.showClaudeNotFound();
process.exit(1);
}
// For claude target, claudeCli is guaranteed non-null after the check above.
// For non-claude targets, CLIProxy flows still need Claude CLI — warn if missing.
const claudeCli = claudeCliRaw || '';
// For non-claude targets, verify target binary exists
if (resolvedTarget !== 'claude') {
const adapter = getTarget(resolvedTarget);
const binaryInfo = adapter.detectBinary();
if (!binaryInfo) {
console.error(fail(`${adapter.displayName} CLI not found.`));
if (resolvedTarget === 'droid') {
console.error(info('Install: npm i -g @factory/cli'));
}
process.exit(1);
}
}
// Use ProfileDetector to determine profile type
const ProfileDetectorModule = await import('./auth/profile-detector');
@@ -623,6 +657,14 @@ async function main(): Promise<void> {
const profileInfo = detector.detectProfileType(profile);
if (profileInfo.type === 'cliproxy') {
// Guard: non-claude targets don't support CLIProxy flow yet
if (resolvedTarget !== 'claude') {
const adapter = getTarget(resolvedTarget);
console.error(fail(`${adapter.displayName} does not support CLIProxy profiles yet`));
console.error(info('Use a settings-based profile with --target instead'));
process.exit(1);
}
// CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants
// Inject WebSearch hook into profile settings before launch
ensureProfileHooks(profileInfo.name);
@@ -641,6 +683,13 @@ async function main(): Promise<void> {
profileName: profileInfo.name,
});
} else if (profileInfo.type === 'copilot') {
// Guard: non-claude targets don't support Copilot flow
if (resolvedTarget !== 'claude') {
const adapter = getTarget(resolvedTarget);
console.error(fail(`${adapter.displayName} does not support Copilot profiles`));
process.exit(1);
}
// COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy
// Inject WebSearch hook into profile settings before launch
ensureProfileHooks(profileInfo.name);
@@ -721,6 +770,12 @@ async function main(): Promise<void> {
// Check if this is GLMT profile (requires proxy)
if (profileInfo.name === 'glmt') {
// Guard: non-claude targets don't support GLMT proxy flow
if (resolvedTarget !== 'claude') {
const adapter = getTarget(resolvedTarget);
console.error(fail(`${adapter.displayName} does not support GLMT proxy profiles`));
process.exit(1);
}
// GLMT FLOW: Settings-based with embedded proxy for thinking support
await execClaudeWithProxy(claudeCli, profileInfo.name, remainingArgs);
} else {
@@ -753,9 +808,34 @@ 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 = getTarget(resolvedTarget);
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);
return;
}
execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs], envVars);
}
} else if (profileInfo.type === 'account') {
// Guard: non-claude targets don't support account profiles
if (resolvedTarget !== 'claude') {
const adapter = getTarget(resolvedTarget);
console.error(fail(`${adapter.displayName} does not support account-based profiles`));
console.error(info('Use a settings-based profile with --target instead'));
process.exit(1);
}
// NEW FLOW: Account-based profile (work, personal)
// All platforms: Use instance isolation with CLAUDE_CONFIG_DIR
const registry = new ProfileRegistry();
@@ -790,6 +870,19 @@ 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 = getTarget(resolvedTarget);
const targetArgs = adapter.buildArgs('default', remainingArgs);
const targetEnv = adapter.buildEnv(
{ profile: 'default', baseUrl: '', apiKey: '' },
'default'
);
adapter.exec(targetArgs, targetEnv);
return;
}
execClaude(claudeCli, remainingArgs, envVars);
}
} catch (error) {
+2
View File
@@ -31,6 +31,8 @@ interface SessionLock {
version?: string;
/** Backend type running (original vs plus) */
backend?: 'original' | 'plus';
/** Target CLI used for this session (default: 'claude') */
target?: string;
}
/** Generate unique session ID */
+6
View File
@@ -301,11 +301,17 @@ 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'],
]);
// Configuration
printConfigSection('Configuration', [
['Config File:', isUnifiedMode() ? `${dirDisplay}/config.yaml` : `${dirDisplay}/config.json`],
+8
View File
@@ -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;
}
/**
+104
View File
@@ -0,0 +1,104 @@
/**
* 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 { escapeShellArg, stripAnthropicEnv } from '../utils/shell-executor';
import { ErrorManager } from '../utils/error-manager';
import { getWebSearchHookEnv } from '../utils/websearch-manager';
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: string): 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 }): void {
const claudeCli = detectClaudeCli();
if (!claudeCli) {
void ErrorManager.showClaudeNotFound();
process.exit(1);
return;
}
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, {
stdio: 'inherit',
windowsHide: true,
shell: true,
env,
});
} else {
child = spawn(claudeCli, args, {
stdio: 'inherit',
windowsHide: true,
env,
});
}
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();
process.exit(1);
});
}
/**
* Claude supports all CCS profile types.
*/
supportsProfileType(_profileType: string): boolean {
return true;
}
}
+98
View File
@@ -0,0 +1,98 @@
/**
* 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 { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from './target-adapter';
import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector';
import { upsertCcsModel } from './droid-config-manager';
import { escapeShellArg } from '../utils/shell-executor';
export class DroidAdapter implements TargetAdapter {
readonly type: TargetType = 'droid';
readonly displayName = 'Factory Droid';
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> {
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: string): NodeJS.ProcessEnv {
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;
}
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, {
stdio: 'inherit',
windowsHide: true,
shell: true,
env,
});
} else {
child = spawn(droidPath, args, {
stdio: 'inherit',
windowsHide: true,
env,
});
}
child.on('exit', (code, signal) => {
if (signal) process.kill(process.pid, signal as NodeJS.Signals);
else process.exit(code || 0);
});
child.on('error', () => {
console.error('[X] Failed to start Droid CLI. Is @factory/cli installed?');
process.exit(1);
});
}
/**
* Droid supports all profile types except account-based.
* Account profiles use CLAUDE_CONFIG_DIR which is Claude-specific.
*/
supportsProfileType(profileType: string): boolean {
return profileType !== 'account';
}
}
+242
View File
@@ -0,0 +1,242 @@
/**
* 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-';
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 extends DroidCustomModel {
/** Internal alias used by CCS for lookup. Stored as the model's display name prefix. */
}
/**
* 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();
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
}
/**
* Read ~/.factory/settings.json, creating empty structure if missing.
*/
function readDroidSettings(): DroidSettings {
const settingsPath = getSettingsPath();
if (!fs.existsSync(settingsPath)) {
return { customModels: [] };
}
const raw = fs.readFileSync(settingsPath, 'utf8');
try {
return JSON.parse(raw) as DroidSettings;
} catch {
// Corrupted file — preserve as backup, start fresh
const backup = settingsPath + '.bak';
fs.copyFileSync(settingsPath, backup);
console.warn(`[!] Corrupted ${settingsPath}, backed up to ${backup}`);
return { customModels: [] };
}
}
/**
* 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();
const tmpPath = settingsPath + '.tmp';
fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', {
encoding: 'utf8',
mode: 0o600,
});
fs.renameSync(tmpPath, 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
}
}
/**
* Build the custom model alias from a CCS profile name.
* e.g., "gemini" → "ccs-gemini"
*/
function ccsAlias(profile: string): string {
return `${CCS_MODEL_PREFIX}${profile}`;
}
/**
* 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> {
ensureFactoryDir();
const settingsPath = getSettingsPath();
// Create file if it doesn't exist (lockfile needs an existing file)
if (!fs.existsSync(settingsPath)) {
writeDroidSettings({ customModels: [] });
}
let release: (() => Promise<void>) | undefined;
try {
release = await lockfile.lock(settingsPath, {
stale: 10000,
retries: { retries: 5, minTimeout: 200, maxTimeout: 1000 },
});
const settings = readDroidSettings();
if (!settings.customModels) {
settings.customModels = [];
}
const alias = ccsAlias(profile);
const entry: DroidCustomModelEntry = {
...model,
displayName: `CCS ${profile}`,
};
// Find existing entry by checking displayName for CCS prefix match
const idx = settings.customModels.findIndex(
(m) => m.displayName === `CCS ${profile}` || m.displayName === alias
);
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> {
const settingsPath = getSettingsPath();
if (!fs.existsSync(settingsPath)) return;
let release: (() => Promise<void>) | undefined;
try {
release = await lockfile.lock(settingsPath, {
stale: 10000,
retries: { retries: 3, minTimeout: 200, maxTimeout: 1000 },
});
const settings = readDroidSettings();
if (!settings.customModels) return;
settings.customModels = settings.customModels.filter(
(m) => m.displayName !== `CCS ${profile}` && m.displayName !== ccsAlias(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();
if (!settings.customModels) return result;
for (const entry of settings.customModels) {
if (entry.displayName?.startsWith('CCS ')) {
const profile = entry.displayName.slice(4); // Remove "CCS " prefix
result.set(profile, entry);
}
}
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> {
const settingsPath = getSettingsPath();
if (!fs.existsSync(settingsPath)) return 0;
let release: (() => Promise<void>) | undefined;
let removed = 0;
try {
release = await lockfile.lock(settingsPath, {
stale: 10000,
retries: { retries: 3, minTimeout: 200, maxTimeout: 1000 },
});
const settings = readDroidSettings();
if (!settings.customModels) return 0;
const before = settings.customModels.length;
settings.customModels = settings.customModels.filter((m) => {
if (!m.displayName?.startsWith('CCS ')) return true; // Keep non-CCS entries
const profile = m.displayName.slice(4);
return activeProfiles.includes(profile);
});
removed = before - settings.customModels.length;
if (removed > 0) {
writeDroidSettings(settings);
}
} finally {
if (release) await release();
}
return removed;
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Droid CLI Detector
*
* Detects Factory Droid CLI binary in PATH.
* Mirrors claude-detector.ts pattern.
*/
import * as fs from 'fs';
import { execSync } 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);
if (fs.existsSync(customPath)) {
return customPath;
}
console.warn('[!] Warning: CCS_DROID_PATH is set but file not found:', customPath);
console.warn(' Falling back to system PATH lookup...');
}
// 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);
if (isWindows) {
const withExtension = matches.find((p) => /\.(exe|cmd|bat|ps1)$/i.test(p));
const droidPath = withExtension || matches[0];
if (droidPath && fs.existsSync(droidPath)) {
return droidPath;
}
} else {
const droidPath = matches[0];
if (droidPath && fs.existsSync(droidPath)) {
return droidPath;
}
}
} 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 = execSync(`"${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
}
}
+30
View File
@@ -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';
+66
View File
@@ -0,0 +1,66 @@
/**
* 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.
*/
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;
/** 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;
}
+52
View File
@@ -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());
}
+79
View File
@@ -0,0 +1,79 @@
/**
* 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']);
/**
* 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 {
// 1. Check --target flag (highest priority)
const targetIdx = args.indexOf('--target');
if (targetIdx !== -1 && args[targetIdx + 1]) {
const flagValue = args[targetIdx + 1];
if (VALID_TARGETS.has(flagValue)) {
return flagValue as TargetType;
}
const available = Array.from(VALID_TARGETS).join(', ');
throw new Error(`Unknown target "${flagValue}". Available: ${available}`);
}
// 2. Check per-profile config
if (profileConfig?.target) {
return profileConfig.target;
}
// 3. Check argv[0] (busybox pattern)
// Strip .cmd/.bat extension for Windows npm shims
const rawBin = path.basename(process.argv[1] || '');
const binName = rawBin.replace(/\.(cmd|bat)$/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[] {
const targetIdx = args.indexOf('--target');
if (targetIdx === -1) return args;
const result = [...args];
// Remove --target and its value
result.splice(targetIdx, 2);
return result;
}
+2
View File
@@ -79,6 +79,8 @@ export interface SessionUsage {
modelsUsed: string[];
modelBreakdowns: ModelBreakdown[];
source: string;
/** Target CLI used for this session (default: 'claude') */
target?: string;
}
// ============================================================================