mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 06:15:03 +00:00
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:
1 parent
8ab78f039c
commit
7d7054e2c0
27 files changed
+3157
-766
No files matched your search
+98
-5
@@ -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) {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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`],
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -79,6 +79,8 @@ export interface SessionUsage {
|
||||
modelsUsed: string[];
|
||||
modelBreakdowns: ModelBreakdown[];
|
||||
source: string;
|
||||
/** Target CLI used for this session (default: 'claude') */
|
||||
target?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in new issue
Block a user