fix(targets): close all remaining multi-target droid edge cases

This commit is contained in:
Tam Nhu Tran
2026-02-17 03:22:45 +07:00
parent 3191a4ab38
commit 0431adf306
21 changed files with 1257 additions and 265 deletions
+43 -29
View File
@@ -95,16 +95,11 @@ export function resolveTargetType(
args: string[],
profileConfig?: { target?: TargetType }
): TargetType {
// 1. Check --target flag
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;
}
// Invalid target → error
console.error(`[X] Unknown target "${flagValue}". Available: claude, droid`);
process.exit(1);
// 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
@@ -113,7 +108,7 @@ export function resolveTargetType(
}
// 3. Check argv[0] (binary name)
const binName = path.basename(process.argv[1] || '').replace(/\.(cmd|bat)$/i, '');
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];
}
@@ -194,8 +189,14 @@ export class ClaudeAdapter implements TargetAdapter {
}
// Handle process termination
process.on('SIGINT', () => child.kill('SIGINT'));
process.on('SIGTERM', () => child.kill('SIGTERM'));
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 {
@@ -253,12 +254,10 @@ export class DroidAdapter implements TargetAdapter {
}
async prepareCredentials(creds: TargetCredentials): Promise<void> {
const profile = creds.envVars?.['CCS_PROFILE_NAME'] || 'default';
// Write custom model entry to ~/.factory/settings.json
await upsertCcsModel(profile, {
await upsertCcsModel(creds.profile, {
model: creds.model || 'claude-opus-4-6',
displayName: `CCS ${profile}`,
displayName: `CCS ${creds.profile}`,
baseUrl: creds.baseUrl,
apiKey: creds.apiKey,
provider: creds.provider || 'anthropic',
@@ -296,13 +295,19 @@ export class DroidAdapter implements TargetAdapter {
}
// Handle process termination
process.on('SIGINT', () => child.kill('SIGINT'));
process.on('SIGTERM', () => child.kill('SIGTERM'));
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 supports all profile types (like Claude)
return true;
// Droid currently supports direct settings/default paths only
return profileType === 'settings' || profileType === 'default';
}
}
```
@@ -313,22 +318,22 @@ export class DroidAdapter implements TargetAdapter {
```json
{
"customModels": {
"ccs-gemini": {
"customModels": [
{
"model": "claude-opus-4-6",
"displayName": "CCS gemini",
"baseUrl": "https://generativelanguage.googleapis.com/v1beta/openai/",
"apiKey": "AIza...",
"provider": "openai"
},
"ccs-glm": {
{
"model": "glm-4",
"displayName": "CCS glm",
"baseUrl": "https://open.bigmodel.cn/api/paas/v4/",
"apiKey": "your-glm-key",
"provider": "openai"
}
}
]
}
```
@@ -349,7 +354,7 @@ ccs --target droid glm
### Binary Alias Pattern
```bash
# Create symlink to auto-select droid target
# Create alias/symlink to auto-select droid target
ln -s /path/to/ccs /path/to/ccsd
# Usage
@@ -358,6 +363,8 @@ ccsd glm
→ droid -m custom:ccs-glm "args..."
```
On Windows, `ccsd.cmd`, `ccsd.bat`, `ccsd.ps1`, and `ccsd.exe` wrappers are also recognized.
---
## Registry and Lookup
@@ -552,8 +559,15 @@ export function escapeShellArg(arg: string): string {
Both adapters propagate signals from parent to child:
```typescript
process.on('SIGINT', () => child.kill('SIGINT'));
process.on('SIGTERM', () => child.kill('SIGTERM'));
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.
@@ -578,7 +592,7 @@ describe('ClaudeAdapter', () => {
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant-...',
model: 'claude-opus-4-6',
}, 'clipproxy');
}, 'cliproxy');
expect(env['ANTHROPIC_AUTH_TOKEN']).toBe('sk-ant-...');
});
+5
View File
@@ -24,6 +24,7 @@ import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loade
import { getCcsDir } from '../utils/config-manager';
import type { CLIProxyProvider } from '../cliproxy/types';
import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities';
import type { TargetType } from '../targets/target-adapter';
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default';
@@ -34,6 +35,7 @@ export type CLIProxyProfileName = CLIProxyProvider;
export interface ProfileDetectionResult {
type: ProfileType;
name: string;
target?: TargetType;
settingsPath?: string;
profile?: Settings | ProfileMetadata;
message?: string;
@@ -143,6 +145,7 @@ class ProfileDetector {
return {
type: 'cliproxy',
name: profileName,
target: composite.target,
provider: defaultTierConfig.provider as CLIProxyProfileName,
settingsPath: composite.settings,
port: composite.port,
@@ -156,6 +159,7 @@ class ProfileDetector {
return {
type: 'cliproxy',
name: profileName,
target: singleVariant.target,
provider: singleVariant.provider as CLIProxyProfileName,
settingsPath: singleVariant.settings,
port: singleVariant.port,
@@ -170,6 +174,7 @@ class ProfileDetector {
return {
type: 'settings',
name: profileName,
target: profile.target,
env: settingsEnv,
};
}
+231 -94
View File
@@ -44,6 +44,7 @@ import {
getTarget,
ClaudeAdapter,
DroidAdapter,
pruneOrphanedModels,
type TargetCredentials,
} from './targets';
import { resolveTargetType, stripTargetFlag } from './targets/target-resolver';
@@ -180,7 +181,8 @@ async function execClaudeWithProxy(
};
const isWindows = process.platform === 'win32';
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli);
const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli);
const webSearchEnv = getWebSearchHookEnv();
const imageAnalysisEnv = getImageAnalysisHookEnv(profileName);
const env = {
@@ -192,7 +194,17 @@ async function execClaudeWithProxy(
};
let claude: ChildProcess;
if (needsShell) {
if (isPowerShellScript) {
claude = spawn(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args],
{
stdio: 'inherit',
windowsHide: true,
env,
}
);
} else if (needsShell) {
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
claude = spawn(cmdString, {
stdio: 'inherit',
@@ -209,28 +221,57 @@ async function execClaudeWithProxy(
}
// 5. Cleanup: kill proxy when Claude exits
const forwardSigTerm = () => {
proxy.kill('SIGTERM');
claude.kill('SIGTERM');
};
const forwardSigInt = () => {
proxy.kill('SIGTERM');
claude.kill('SIGINT');
};
const forwardSighup = () => {
proxy.kill('SIGTERM');
claude.kill('SIGHUP');
};
process.on('SIGTERM', forwardSigTerm);
process.on('SIGINT', forwardSigInt);
process.on('SIGHUP', forwardSighup);
const cleanupSignalHandlers = () => {
process.removeListener('SIGTERM', forwardSigTerm);
process.removeListener('SIGINT', forwardSigInt);
process.removeListener('SIGHUP', forwardSighup);
};
claude.on('exit', (code, signal) => {
cleanupSignalHandlers();
proxy.kill('SIGTERM');
if (signal) process.kill(process.pid, signal as NodeJS.Signals);
else process.exit(code || 0);
});
claude.on('error', (error) => {
console.error(fail(`Claude CLI error: ${error}`));
cleanupSignalHandlers();
const err = error as NodeJS.ErrnoException;
if (err.code === 'EACCES') {
console.error(fail(`Claude CLI is not executable: ${claudeCli}`));
console.error(' Check file permissions and executable bit.');
} else if (err.code === 'ENOENT') {
if (isPowerShellScript) {
console.error(fail('PowerShell executable not found (required for .ps1 wrapper launch).'));
console.error(' Ensure powershell.exe is available in PATH.');
} else if (needsShell) {
console.error(fail('Windows command shell not found for Claude wrapper launch.'));
console.error(' Ensure cmd.exe is available and accessible.');
} else {
console.error(fail(`Claude CLI not found: ${claudeCli}`));
}
} else {
console.error(fail(`Claude CLI error: ${err.message}`));
}
proxy.kill('SIGTERM');
process.exit(1);
});
// Also handle parent process termination
process.once('SIGTERM', () => {
proxy.kill('SIGTERM');
claude.kill('SIGTERM');
});
process.once('SIGINT', () => {
proxy.kill('SIGTERM');
claude.kill('SIGTERM');
});
}
// ========== Main Execution ==========
@@ -399,7 +440,8 @@ async function main(): Promise<void> {
// Special case: version command (check BEFORE profile detection)
if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') {
handleVersionCommand();
await handleVersionCommand();
return;
}
// Special case: help command
@@ -575,33 +617,6 @@ async function main(): Promise<void> {
process.exit(exitCode);
}
// Special case: headless delegation (-p flag)
if (args.includes('-p') || args.includes('--prompt')) {
// CLIProxy profiles (codex/gemini/agy/etc, including user variants) must stay on
// the normal CLIProxy path so provider-specific flags (e.g. --effort/--thinking)
// and proxy chains are applied consistently.
let shouldUseDelegation = true;
const { profile } = detectProfile(args);
try {
const ProfileDetectorModule = await import('./auth/profile-detector');
const ProfileDetector = ProfileDetectorModule.default;
const detector = new ProfileDetector();
const profileInfo = detector.detectProfileType(profile);
if (profileInfo.type === 'cliproxy') {
shouldUseDelegation = false;
}
} catch {
// Best effort detection only; keep delegation fallback behavior.
}
if (shouldUseDelegation) {
const { DelegationHandler } = await import('./delegation/delegation-handler');
const handler = new DelegationHandler();
await handler.route(args);
return;
}
}
// First-time install: offer setup wizard for interactive users
// Check independently of recovery status (user may have empty config.yaml)
// Skip if headless, CI, or non-TTY environment
@@ -613,36 +628,6 @@ async function main(): Promise<void> {
console.log('');
}
// Detect profile (strip --target from args before profile detection)
const cleanArgs = stripTargetFlag(args);
const { profile, remainingArgs } = detectProfile(cleanArgs);
// 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');
const ProfileDetector = ProfileDetectorModule.default;
@@ -654,14 +639,132 @@ async function main(): Promise<void> {
const detector = new ProfileDetector();
try {
// Detect profile (strip --target flags before profile detection)
const cleanArgs = stripTargetFlag(args);
const { profile, remainingArgs } = detectProfile(cleanArgs);
const profileInfo = detector.detectProfileType(profile);
let resolvedTarget: ReturnType<typeof resolveTargetType>;
try {
resolvedTarget = resolveTargetType(
args,
profileInfo.target ? { target: profileInfo.target } : undefined
);
} catch (error) {
console.error(fail((error as Error).message));
process.exit(1);
return;
}
// Detect Claude CLI (needed for claude target and all CLIProxy-derived flows)
const claudeCliRaw = detectClaudeCli();
if (resolvedTarget === 'claude' && !claudeCliRaw) {
await ErrorManager.showClaudeNotFound();
process.exit(1);
}
const claudeCli = claudeCliRaw || '';
// Resolve non-claude target adapter once.
const targetAdapter = resolvedTarget !== 'claude' ? getTarget(resolvedTarget) : null;
// Preflight unsupported profile/target combinations BEFORE binary detection,
// so users get the most actionable error even when the target CLI is not installed.
if (resolvedTarget !== 'claude') {
if (!targetAdapter) {
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
process.exit(1);
}
if (profileInfo.type === 'cliproxy' && !targetAdapter.supportsProfileType('cliproxy')) {
console.error(fail(`${targetAdapter.displayName} does not support CLIProxy profiles`));
console.error(info('Use a settings-based profile with --target instead'));
process.exit(1);
}
if (profileInfo.type === 'copilot' && !targetAdapter.supportsProfileType('copilot')) {
console.error(fail(`${targetAdapter.displayName} does not support Copilot profiles`));
process.exit(1);
}
if (profileInfo.type === 'account' && !targetAdapter.supportsProfileType('account')) {
console.error(fail(`${targetAdapter.displayName} does not support account-based profiles`));
console.error(info('Use a settings-based profile with --target instead'));
process.exit(1);
}
// GLMT always requires Claude target because it depends on embedded proxy flow.
if (profileInfo.type === 'settings' && profileInfo.name === 'glmt') {
console.error(fail(`${targetAdapter.displayName} does not support GLMT proxy profiles`));
console.error(
info('Use --target claude for glmt, or switch to a direct API profile (glm/kimi)')
);
process.exit(1);
}
if (profileInfo.type === 'default') {
if (!targetAdapter.supportsProfileType('default')) {
console.error(fail(`${targetAdapter.displayName} does not support default profile mode`));
process.exit(1);
}
// For default mode, Droid requires explicit credentials from environment.
if (resolvedTarget === 'droid') {
const baseUrl = process.env['ANTHROPIC_BASE_URL'] || '';
const apiKey = process.env['ANTHROPIC_AUTH_TOKEN'] || '';
if (!baseUrl.trim() || !apiKey.trim()) {
console.error(
fail(
`${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN`
)
);
console.error(info('Use a settings-based profile instead: ccs glm --target droid'));
process.exit(1);
}
}
}
}
// For non-claude targets, verify target binary exists once and pass it through.
const targetBinaryInfo = targetAdapter?.detectBinary() ?? null;
if (resolvedTarget !== 'claude' && (!targetAdapter || !targetBinaryInfo)) {
const displayName = targetAdapter?.displayName || resolvedTarget;
console.error(fail(`${displayName} CLI not found.`));
if (resolvedTarget === 'droid') {
console.error(info('Install: npm i -g @factory/cli'));
}
process.exit(1);
}
// Best-effort: prune stale Droid model entries at runtime so settings.json stays clean.
if (resolvedTarget === 'droid') {
try {
const allProfiles = detector.getAllProfiles();
const activeProfiles = allProfiles.settings.filter((name) => /^[a-zA-Z0-9_-]+$/.test(name));
await pruneOrphanedModels(activeProfiles);
} catch (error) {
console.error(warn(`[!] Droid prune skipped: ${(error as Error).message}`));
}
}
// Special case: headless delegation (-p/--prompt)
// Keep existing behavior for Claude targets only; non-claude targets must continue
// through normal adapter dispatch logic.
if (args.includes('-p') || args.includes('--prompt')) {
const shouldUseDelegation = resolvedTarget === 'claude' && profileInfo.type !== 'cliproxy';
if (shouldUseDelegation) {
const { DelegationHandler } = await import('./delegation/delegation-handler');
const handler = new DelegationHandler();
await handler.route(cleanArgs);
return;
}
}
if (profileInfo.type === 'cliproxy') {
// Guard: non-claude targets don't support CLIProxy flow yet
if (resolvedTarget !== 'claude') {
const adapter = getTarget(resolvedTarget);
if (!adapter.supportsProfileType('cliproxy')) {
console.error(fail(`${adapter.displayName} does not support CLIProxy profiles yet`));
if (!targetAdapter?.supportsProfileType('cliproxy')) {
console.error(
fail(`${targetAdapter?.displayName || 'Target'} does not support CLIProxy profiles`)
);
console.error(info('Use a settings-based profile with --target instead'));
process.exit(1);
}
@@ -687,9 +790,10 @@ async function main(): Promise<void> {
} else if (profileInfo.type === 'copilot') {
// Guard: non-claude targets don't support Copilot flow
if (resolvedTarget !== 'claude') {
const adapter = getTarget(resolvedTarget);
if (!adapter.supportsProfileType('copilot')) {
console.error(fail(`${adapter.displayName} does not support Copilot profiles`));
if (!targetAdapter?.supportsProfileType('copilot')) {
console.error(
fail(`${targetAdapter?.displayName || 'Target'} does not support Copilot profiles`)
);
process.exit(1);
}
}
@@ -774,13 +878,14 @@ 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);
if (!adapter.supportsProfileType('settings')) {
console.error(fail(`${adapter.displayName} does not support GLMT proxy profiles`));
process.exit(1);
}
console.error(
fail(`${targetAdapter?.displayName || 'Target'} does not support GLMT proxy profiles`)
);
console.error(
info('Use --target claude for glmt, or switch to a direct API profile (glm/kimi)')
);
process.exit(1);
}
// GLMT FLOW: Settings-based with embedded proxy for thinking support
await execClaudeWithProxy(claudeCli, profileInfo.name, remainingArgs);
@@ -817,7 +922,11 @@ async function main(): Promise<void> {
// Dispatch through target adapter for non-claude targets
if (resolvedTarget !== 'claude') {
const adapter = getTarget(resolvedTarget);
const adapter = targetAdapter;
if (!adapter) {
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
process.exit(1);
}
const creds: TargetCredentials = {
profile: profileInfo.name,
baseUrl: settingsEnv['ANTHROPIC_BASE_URL'] || '',
@@ -827,7 +936,7 @@ async function main(): Promise<void> {
await adapter.prepareCredentials(creds);
const targetArgs = adapter.buildArgs(profileInfo.name, remainingArgs);
const targetEnv = adapter.buildEnv(creds, profileInfo.type);
adapter.exec(targetArgs, targetEnv);
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
return;
}
@@ -836,9 +945,12 @@ async function main(): Promise<void> {
} else if (profileInfo.type === 'account') {
// Guard: non-claude targets don't support account profiles
if (resolvedTarget !== 'claude') {
const adapter = getTarget(resolvedTarget);
if (!adapter.supportsProfileType('account')) {
console.error(fail(`${adapter.displayName} does not support account-based profiles`));
if (!targetAdapter?.supportsProfileType('account')) {
console.error(
fail(
`${targetAdapter?.displayName || 'Target'} does not support account-based profiles`
)
);
console.error(info('Use a settings-based profile with --target instead'));
process.exit(1);
}
@@ -881,16 +993,34 @@ async function main(): Promise<void> {
// Dispatch through target adapter for non-claude targets
if (resolvedTarget !== 'claude') {
const adapter = getTarget(resolvedTarget);
const adapter = targetAdapter;
if (!adapter) {
console.error(fail(`Target adapter not found for "${resolvedTarget}"`));
process.exit(1);
}
if (!adapter.supportsProfileType('default')) {
console.error(fail(`${adapter.displayName} does not support default profile mode`));
process.exit(1);
}
const creds: TargetCredentials = { profile: 'default', baseUrl: '', apiKey: '' };
const creds: TargetCredentials = {
profile: 'default',
baseUrl: process.env['ANTHROPIC_BASE_URL'] || '',
apiKey: process.env['ANTHROPIC_AUTH_TOKEN'] || '',
model: process.env['ANTHROPIC_MODEL'],
};
if (!creds.baseUrl || !creds.apiKey) {
console.error(
fail(
`${adapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN`
)
);
console.error(info('Use a settings-based profile instead: ccs glm --target droid'));
process.exit(1);
}
await adapter.prepareCredentials(creds);
const targetArgs = adapter.buildArgs('default', remainingArgs);
const targetEnv = adapter.buildEnv(creds, 'default');
adapter.exec(targetArgs, targetEnv);
adapter.exec(targetArgs, targetEnv, { binaryInfo: targetBinaryInfo || undefined });
return;
}
@@ -924,12 +1054,19 @@ process.on('unhandledRejection', (reason: unknown) => {
// Handle process termination signals for cleanup
process.on('SIGTERM', () => {
runCleanup();
process.exit(0);
// If a target exec path registered additional signal listeners, let those
// listeners forward/coordinate child shutdown and final exit codes.
if (process.listenerCount('SIGTERM') <= 1) {
process.exit(143); // 128 + SIGTERM(15)
}
});
process.on('SIGINT', () => {
runCleanup();
process.exit(130); // 128 + SIGINT(2)
// Same coordination rule as SIGTERM.
if (process.listenerCount('SIGINT') <= 1) {
process.exit(130); // 128 + SIGINT(2)
}
});
// Run main
+112 -40
View File
@@ -17,6 +17,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import * as lockfile from 'proper-lockfile';
import { getCliproxyDir } from './config-generator';
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
import { CLIPROXY_DEFAULT_PORT } from './config-generator';
@@ -31,8 +32,10 @@ interface SessionLock {
version?: string;
/** Backend type running (original vs plus) */
backend?: 'original' | 'plus';
/** Target CLI used for this session (default: 'claude') */
/** Target summary for active sessions ('mixed' when multiple targets share the proxy) */
target?: string;
/** Per-session target metadata */
sessionTargets?: Record<string, string>;
}
/** Generate unique session ID */
@@ -48,6 +51,34 @@ function getSessionLockPathForPort(port: number): string {
return path.join(getCliproxyDir(), `sessions-${port}.json`);
}
function ensureCliproxyDir(): string {
const dir = getCliproxyDir();
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
return dir;
}
function withSessionTrackerLock<T>(fn: () => T): T {
const dir = ensureCliproxyDir();
let release: (() => void) | undefined;
try {
release = lockfile.lockSync(dir, {
stale: 10000,
}) as () => void;
return fn();
} finally {
if (release) {
try {
release();
} catch {
// Best-effort release
}
}
}
}
/** Get path to session lock file (default port) - kept for future use */
function _getSessionLockPath(): string {
return getSessionLockPathForPort(CLIPROXY_DEFAULT_PORT);
@@ -87,13 +118,24 @@ function readSessionLock(): SessionLock | null {
return readSessionLockForPort(CLIPROXY_DEFAULT_PORT);
}
function getTargetSummary(lock: SessionLock): string | undefined {
const targets = lock.sessionTargets ? Object.values(lock.sessionTargets).filter(Boolean) : [];
if (targets.length === 0) {
return lock.target;
}
const unique = new Set(targets);
if (unique.size === 1) {
return targets[0];
}
return 'mixed';
}
/** Write session lock file for specific port */
function writeSessionLockForPort(lock: SessionLock): void {
const lockPath = getSessionLockPathForPort(lock.port);
const dir = path.dirname(lockPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
ensureCliproxyDir();
lock.target = getTargetSummary(lock);
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2), { mode: 0o600 });
}
@@ -186,16 +228,24 @@ export function registerSession(
port: number,
proxyPid: number,
version?: string,
backend?: 'original' | 'plus'
backend?: 'original' | 'plus',
target?: string
): string {
const sessionId = generateSessionId();
const existingLock = readSessionLockForPort(port);
withSessionTrackerLock(() => {
const existingLock = readSessionLockForPort(port);
if (existingLock && existingLock.port === port && existingLock.pid === proxyPid) {
// Add to existing sessions
existingLock.sessions.push(sessionId);
if (target) {
existingLock.sessionTargets = existingLock.sessionTargets || {};
existingLock.sessionTargets[sessionId] = target;
}
writeSessionLockForPort(existingLock);
return;
}
if (existingLock && existingLock.port === port && existingLock.pid === proxyPid) {
// Add to existing sessions
existingLock.sessions.push(sessionId);
writeSessionLockForPort(existingLock);
} else {
// Create new lock (first session for this proxy)
const newLock: SessionLock = {
port,
@@ -204,9 +254,11 @@ export function registerSession(
startedAt: new Date().toISOString(),
version,
backend,
target,
sessionTargets: target ? { [sessionId]: target } : undefined,
};
writeSessionLockForPort(newLock);
}
});
return sessionId;
}
@@ -220,48 +272,66 @@ export function registerSession(
export function unregisterSession(sessionId: string, port?: number): boolean {
// If port provided, use port-specific lookup
if (port !== undefined) {
const lock = readSessionLockForPort(port);
return withSessionTrackerLock(() => {
const lock = readSessionLockForPort(port);
if (!lock) {
return true;
}
const index = lock.sessions.indexOf(sessionId);
if (index !== -1) {
lock.sessions.splice(index, 1);
}
if (lock.sessionTargets) {
delete lock.sessionTargets[sessionId];
if (Object.keys(lock.sessionTargets).length === 0) {
delete lock.sessionTargets;
}
}
if (lock.sessions.length === 0) {
deleteSessionLockForPort(port);
return true;
}
writeSessionLockForPort(lock);
return false;
});
}
// Fallback: search default port (backward compat)
return withSessionTrackerLock(() => {
const lock = readSessionLock();
if (!lock) {
// No lock file - assume we're the only session
return true;
}
// Remove this session from the list
const index = lock.sessions.indexOf(sessionId);
if (index !== -1) {
lock.sessions.splice(index, 1);
}
if (lock.sessionTargets) {
delete lock.sessionTargets[sessionId];
if (Object.keys(lock.sessionTargets).length === 0) {
delete lock.sessionTargets;
}
}
// Check if any sessions remain
if (lock.sessions.length === 0) {
deleteSessionLockForPort(port);
// Last session - clean up lock file
deleteSessionLock();
return true;
}
// Other sessions still active - keep proxy running
writeSessionLockForPort(lock);
return false;
}
// Fallback: search default port (backward compat)
const lock = readSessionLock();
if (!lock) {
// No lock file - assume we're the only session
return true;
}
// Remove this session from the list
const index = lock.sessions.indexOf(sessionId);
if (index !== -1) {
lock.sessions.splice(index, 1);
}
// Check if any sessions remain
if (lock.sessions.length === 0) {
// Last session - clean up lock file
deleteSessionLock();
return true;
}
// Other sessions still active - keep proxy running
writeSessionLockForPort(lock);
return false;
});
}
/**
@@ -422,6 +492,7 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): {
sessionCount?: number;
startedAt?: string;
version?: string;
target?: string;
} {
const lock = readSessionLockForPort(port);
@@ -442,6 +513,7 @@ export function getProxyStatus(port: number = CLIPROXY_DEFAULT_PORT): {
sessionCount: lock.sessions.length,
startedAt: lock.startedAt,
version: lock.version,
target: lock.target || 'claude',
};
}
+56 -5
View File
@@ -56,7 +56,11 @@ export class ClaudeAdapter implements TargetAdapter {
return env;
}
exec(args: string[], env: NodeJS.ProcessEnv, _options?: { cwd?: string }): void {
exec(
args: string[],
env: NodeJS.ProcessEnv,
_options?: { cwd?: string; binaryInfo?: TargetBinaryInfo }
): void {
const claudeCli = detectClaudeCli();
if (!claudeCli) {
void ErrorManager.showClaudeNotFound();
@@ -65,10 +69,21 @@ export class ClaudeAdapter implements TargetAdapter {
}
const isWindows = process.platform === 'win32';
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli);
const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli);
let child: ChildProcess;
if (needsShell) {
if (isPowerShellScript) {
child = spawn(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args],
{
stdio: 'inherit',
windowsHide: true,
env,
}
);
} else if (needsShell) {
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
child = spawn(cmdString, {
stdio: 'inherit',
@@ -84,13 +99,49 @@ export class ClaudeAdapter implements TargetAdapter {
});
}
const forwardSigInt = () => {
if (!child.killed) child.kill('SIGINT');
};
const forwardSigTerm = () => {
if (!child.killed) child.kill('SIGTERM');
};
const forwardSighup = () => {
if (!child.killed) child.kill('SIGHUP');
};
process.on('SIGINT', forwardSigInt);
process.on('SIGTERM', forwardSigTerm);
process.on('SIGHUP', forwardSighup);
const cleanupSignalHandlers = () => {
process.removeListener('SIGINT', forwardSigInt);
process.removeListener('SIGTERM', forwardSigTerm);
process.removeListener('SIGHUP', forwardSighup);
};
child.on('exit', (code, signal) => {
cleanupSignalHandlers();
if (signal) process.kill(process.pid, signal as NodeJS.Signals);
else process.exit(code || 0);
});
child.on('error', async () => {
await ErrorManager.showClaudeNotFound();
child.on('error', async (err: NodeJS.ErrnoException) => {
cleanupSignalHandlers();
if (err.code === 'EACCES') {
console.error(`[X] Claude CLI is not executable: ${claudeCli}`);
console.error(' Check file permissions and executable bit.');
} else if (err.code === 'ENOENT') {
if (isPowerShellScript) {
console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).');
console.error(' Ensure powershell.exe is available in PATH.');
} else if (needsShell) {
console.error('[X] Windows command shell not found for Claude wrapper launch.');
console.error(' Ensure cmd.exe is available and accessible.');
} else {
await ErrorManager.showClaudeNotFound();
}
} else {
console.error(`[X] Failed to start Claude CLI (${claudeCli}): ${err.message}`);
}
process.exit(1);
});
}
+81 -10
View File
@@ -6,6 +6,7 @@
*/
import { spawn, ChildProcess } from 'child_process';
import * as fs from 'fs';
import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from './target-adapter';
import { getDroidBinaryInfo, detectDroidCli, checkDroidVersion } from './droid-detector';
import { upsertCcsModel } from './droid-config-manager';
@@ -15,6 +16,15 @@ export class DroidAdapter implements TargetAdapter {
readonly type: TargetType = 'droid';
readonly displayName = 'Factory Droid';
private validateCredentials(creds: TargetCredentials): void {
if (!creds.baseUrl?.trim()) {
throw new Error('Droid target requires ANTHROPIC_BASE_URL');
}
if (!creds.apiKey?.trim()) {
throw new Error('Droid target requires ANTHROPIC_AUTH_TOKEN');
}
}
detectBinary(): TargetBinaryInfo | null {
const info = getDroidBinaryInfo();
if (!info) return null;
@@ -29,6 +39,7 @@ export class DroidAdapter implements TargetAdapter {
* This is the key difference from Claude — Droid reads config files, not env vars.
*/
async prepareCredentials(creds: TargetCredentials): Promise<void> {
this.validateCredentials(creds);
await upsertCcsModel(creds.profile, {
model: creds.model || 'claude-opus-4-6',
displayName: `CCS ${creds.profile}`,
@@ -49,19 +60,49 @@ export class DroidAdapter implements TargetAdapter {
return { ...process.env };
}
exec(args: string[], env: NodeJS.ProcessEnv, _options?: { cwd?: string }): void {
const droidPath = detectDroidCli();
exec(
args: string[],
env: NodeJS.ProcessEnv,
options?: { cwd?: string; binaryInfo?: TargetBinaryInfo }
): void {
const droidPath = options?.binaryInfo?.path || detectDroidCli();
if (!droidPath) {
console.error('[X] Droid CLI not found. Install: npm i -g @factory/cli');
process.exit(1);
return;
}
try {
const stat = fs.statSync(droidPath);
if (!stat.isFile()) {
console.error(`[X] Droid CLI path is not a file: ${droidPath}`);
process.exit(1);
return;
}
} catch (err) {
const error = err as NodeJS.ErrnoException;
console.error(
`[X] Droid CLI path is not accessible (${error.code || 'unknown'}): ${droidPath}`
);
process.exit(1);
return;
}
const isWindows = process.platform === 'win32';
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(droidPath);
const isPowerShellScript = isWindows && /\.ps1$/i.test(droidPath);
const needsShell = isWindows && /\.(cmd|bat)$/i.test(droidPath);
let child: ChildProcess;
if (needsShell) {
if (isPowerShellScript) {
child = spawn(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', droidPath, ...args],
{
stdio: 'inherit',
windowsHide: true,
env,
}
);
} else if (needsShell) {
const cmdString = [droidPath, ...args].map(escapeShellArg).join(' ');
child = spawn(cmdString, {
stdio: 'inherit',
@@ -77,28 +118,58 @@ export class DroidAdapter implements TargetAdapter {
});
}
const forwardSigInt = () => {
if (!child.killed) child.kill('SIGINT');
};
const forwardSigTerm = () => {
if (!child.killed) child.kill('SIGTERM');
};
const forwardSighup = () => {
if (!child.killed) child.kill('SIGHUP');
};
process.on('SIGINT', forwardSigInt);
process.on('SIGTERM', forwardSigTerm);
process.on('SIGHUP', forwardSighup);
const cleanupSignalHandlers = () => {
process.removeListener('SIGINT', forwardSigInt);
process.removeListener('SIGTERM', forwardSigTerm);
process.removeListener('SIGHUP', forwardSighup);
};
child.on('exit', (code, signal) => {
cleanupSignalHandlers();
if (signal) process.kill(process.pid, signal as NodeJS.Signals);
else process.exit(code || 0);
});
child.on('error', (err: NodeJS.ErrnoException) => {
cleanupSignalHandlers();
if (err.code === 'EACCES') {
console.error('[X] Droid CLI not executable. Check file permissions.');
console.error(`[X] Droid CLI is not executable: ${droidPath}`);
console.error(' Check file permissions and executable bit.');
} else if (err.code === 'ENOENT') {
console.error('[X] Droid CLI not found. Install: npm i -g @factory/cli');
if (isPowerShellScript) {
console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).');
console.error(' Ensure powershell.exe is available in PATH.');
} else if (needsShell) {
console.error('[X] Windows command shell not found for Droid wrapper launch.');
console.error(' Ensure cmd.exe is available and accessible.');
} else {
console.error(`[X] Droid CLI not found: ${droidPath}`);
console.error(' Install: npm i -g @factory/cli');
}
} else {
console.error('[X] Failed to start Droid CLI:', err.message);
console.error(`[X] Failed to start Droid CLI (${droidPath}):`, err.message);
}
process.exit(1);
});
}
/**
* Droid supports all profile types except account-based.
* Account profiles use CLAUDE_CONFIG_DIR which is Claude-specific.
* Droid currently supports direct settings-based and default flows only.
*/
supportsProfileType(profileType: string): boolean {
return profileType !== 'account';
return profileType === 'settings' || profileType === 'default';
}
}
+171 -39
View File
@@ -38,10 +38,54 @@ interface DroidSettings {
[key: string]: unknown;
}
interface DroidCustomModelEntry extends DroidCustomModel {
interface DroidCustomModelEntry {
model: string;
displayName: string;
baseUrl: string;
apiKey: string;
provider: string;
maxOutputTokens?: number;
/** Internal alias used by CCS for lookup. Stored as the model's display name prefix. */
}
function isSupportedProvider(value: string): value is DroidCustomModel['provider'] {
return value === 'anthropic' || value === 'openai' || value === 'generic-chat-completion-api';
}
function asModelEntry(value: unknown): DroidCustomModelEntry | null {
if (!value || typeof value !== 'object') return null;
const record = value as Record<string, unknown>;
if (
typeof record.displayName !== 'string' ||
record.displayName.trim() === '' ||
typeof record.model !== 'string' ||
typeof record.baseUrl !== 'string' ||
typeof record.apiKey !== 'string' ||
typeof record.provider !== 'string' ||
record.provider.trim() === ''
) {
return null;
}
return value as DroidCustomModelEntry;
}
function normalizeCustomModels(value: unknown): DroidCustomModelEntry[] {
if (Array.isArray(value)) {
return value
.map((entry) => asModelEntry(entry))
.filter((entry): entry is DroidCustomModelEntry => !!entry);
}
// Accept legacy object-map shapes and normalize to array.
if (value && typeof value === 'object') {
return Object.values(value)
.map((entry) => asModelEntry(entry))
.filter((entry): entry is DroidCustomModelEntry => !!entry);
}
return [];
}
/**
* Get path to ~/.factory/settings.json.
* Respects CCS_HOME for test isolation (uses CCS_HOME/.factory/ in tests).
@@ -65,6 +109,35 @@ function ensureFactoryDir(): void {
}
}
function getNoFollowFlag(): number {
const candidate = (fs.constants as Record<string, number>)['O_NOFOLLOW'];
if (process.platform !== 'win32' && typeof candidate === 'number') {
return candidate;
}
return 0;
}
function openFileNoFollow(filePath: string, flags: number, mode?: number): number {
const safeFlags = flags | getNoFollowFlag();
if (mode === undefined) {
return fs.openSync(filePath, safeFlags);
}
return fs.openSync(filePath, safeFlags, mode);
}
function readFileUtf8NoFollow(filePath: string): string {
const fd = openFileNoFollow(filePath, fs.constants.O_RDONLY);
try {
const stat = fs.fstatSync(fd);
if (!stat.isFile()) {
throw new Error('Refusing to read: settings.json is not a regular file');
}
return fs.readFileSync(fd, 'utf8');
} finally {
fs.closeSync(fd);
}
}
/**
* Read ~/.factory/settings.json, creating empty structure if missing.
*/
@@ -74,19 +147,63 @@ function readDroidSettings(): DroidSettings {
return { customModels: [] };
}
const raw = fs.readFileSync(settingsPath, 'utf8');
const fileStat = fs.lstatSync(settingsPath);
if (fileStat.isSymbolicLink()) {
throw new Error('Refusing to read: settings.json is a symlink');
}
if (!fileStat.isFile()) {
throw new Error('Refusing to read: settings.json is not a regular file');
}
const raw = readFileUtf8NoFollow(settingsPath);
try {
return JSON.parse(raw) as DroidSettings;
const parsed = JSON.parse(raw) as DroidSettings;
return {
...parsed,
customModels: normalizeCustomModels((parsed as { customModels?: unknown }).customModels),
};
} catch {
// Corrupted file — preserve as backup, start fresh
const backup = settingsPath + '.bak';
fs.copyFileSync(settingsPath, backup);
fs.chmodSync(backup, 0o600); // Secure backup permissions
console.warn(`[!] Corrupted ${settingsPath}, backed up to ${backup}`);
try {
fs.copyFileSync(settingsPath, backup);
fs.chmodSync(backup, 0o600); // Secure backup permissions
console.warn(`[!] Corrupted ${settingsPath}, backed up to ${backup}`);
} catch (error) {
console.warn(`[!] Corrupted ${settingsPath}; backup failed: ${(error as Error).message}`);
}
return { customModels: [] };
}
}
async function acquireFactoryLock(retries: number): Promise<() => Promise<void>> {
ensureFactoryDir();
const factoryDir = getFactoryDir();
try {
return await lockfile.lock(factoryDir, {
stale: 10000,
retries: { retries, minTimeout: 200, maxTimeout: 1000 },
});
} catch (error) {
throw new Error(
`Failed to lock Droid settings directory (${factoryDir}): ${(error as Error).message}`
);
}
}
function fsyncDir(dirPath: string): void {
try {
const dirFd = fs.openSync(dirPath, fs.constants.O_RDONLY);
try {
fs.fsyncSync(dirFd);
} finally {
fs.closeSync(dirFd);
}
} catch {
// Best-effort directory fsync (platform dependent).
}
}
/**
* Write ~/.factory/settings.json atomically with safe permissions.
* Uses temp file + rename for atomicity on same filesystem.
@@ -104,12 +221,38 @@ function writeDroidSettings(settings: DroidSettings): void {
}
const tmpPath = settingsPath + '.tmp';
if (fs.existsSync(tmpPath)) {
const tmpStat = fs.lstatSync(tmpPath);
if (tmpStat.isSymbolicLink()) {
throw new Error('Refusing to write: settings.json.tmp is a symlink');
}
}
fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', {
encoding: 'utf8',
mode: 0o600,
});
const payload = JSON.stringify(
{
...settings,
customModels: normalizeCustomModels((settings as { customModels?: unknown }).customModels),
},
null,
2
);
const fd = openFileNoFollow(
tmpPath,
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC,
0o600
);
try {
const tmpFdStat = fs.fstatSync(fd);
if (!tmpFdStat.isFile()) {
throw new Error('Refusing to write: settings.json.tmp is not a regular file');
}
fs.writeFileSync(fd, payload + '\n', { encoding: 'utf8' });
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmpPath, settingsPath);
fsyncDir(path.dirname(settingsPath));
// Fix permissions on existing file if world-readable
try {
@@ -139,24 +282,13 @@ function ccsAlias(profile: string): string {
export async function upsertCcsModel(profile: string, model: DroidCustomModel): Promise<void> {
validateProfileName(profile);
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 },
});
release = await acquireFactoryLock(5);
const settings = readDroidSettings();
if (!settings.customModels) {
settings.customModels = [];
}
settings.customModels = normalizeCustomModels(settings.customModels);
const alias = ccsAlias(profile);
const entry: DroidCustomModelEntry = {
@@ -186,18 +318,16 @@ export async function upsertCcsModel(profile: string, model: DroidCustomModel):
*/
export async function removeCcsModel(profile: string): Promise<void> {
validateProfileName(profile);
ensureFactoryDir();
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 },
});
release = await acquireFactoryLock(3);
if (!fs.existsSync(settingsPath)) return;
const settings = readDroidSettings();
if (!settings.customModels) return;
settings.customModels = normalizeCustomModels(settings.customModels);
settings.customModels = settings.customModels.filter(
(m) => m.displayName !== `CCS ${profile}` && m.displayName !== ccsAlias(profile)
@@ -215,12 +345,16 @@ export async function removeCcsModel(profile: string): Promise<void> {
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) {
for (const entry of normalizeCustomModels(settings.customModels)) {
if (entry.displayName?.startsWith('CCS ')) {
if (!isSupportedProvider(entry.provider)) {
continue;
}
const profile = entry.displayName.slice(4); // Remove "CCS " prefix
result.set(profile, entry);
result.set(profile, {
...entry,
provider: entry.provider,
});
}
}
@@ -235,20 +369,18 @@ export async function pruneOrphanedModels(activeProfiles: string[]): Promise<num
// Validate all profile names before pruning
activeProfiles.forEach((profile) => validateProfileName(profile));
ensureFactoryDir();
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 },
});
release = await acquireFactoryLock(3);
if (!fs.existsSync(settingsPath)) return 0;
const settings = readDroidSettings();
if (!settings.customModels) return 0;
settings.customModels = normalizeCustomModels(settings.customModels);
const before = settings.customModels.length;
settings.customModels = settings.customModels.filter((m) => {
+31 -17
View File
@@ -6,7 +6,7 @@
*/
import * as fs from 'fs';
import { execSync } from 'child_process';
import { execSync, execFileSync } from 'child_process';
import { expandPath } from '../utils/helpers';
import { TargetBinaryInfo } from './target-adapter';
@@ -21,15 +21,25 @@ 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)) {
try {
if (fs.statSync(customPath).isFile()) {
return customPath;
}
console.warn('[!] CCS_DROID_PATH points to a directory, not a file:', customPath);
console.warn(' Falling back to system PATH lookup...');
} else {
console.warn('[!] Warning: CCS_DROID_PATH is set but file not found:', customPath);
console.warn(' Falling back to system PATH lookup...');
console.warn(' Refusing PATH fallback while CCS_DROID_PATH is explicitly set.');
return null;
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code === 'ENOENT') {
console.warn('[!] Warning: CCS_DROID_PATH is set but file not found:', customPath);
} else {
console.warn(
`[!] Warning: CCS_DROID_PATH is not accessible (${error.code || 'unknown error'}):`,
customPath
);
}
console.warn(' Refusing PATH fallback while CCS_DROID_PATH is explicitly set.');
return null;
}
}
@@ -49,16 +59,20 @@ export function detectDroidCli(): string | null {
.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;
const candidates = isWindows
? [
...matches.filter((p) => /\.(exe|cmd|bat|ps1)$/i.test(p)),
...matches.filter((p) => !/\.(exe|cmd|bat|ps1)$/i.test(p)),
]
: matches;
for (const candidate of candidates) {
try {
if (fs.statSync(candidate).isFile()) {
return candidate;
}
} catch {
// Ignore unreadable or disappearing path candidates and try next one
}
}
} catch {
@@ -87,7 +101,7 @@ export function getDroidBinaryInfo(): TargetBinaryInfo | null {
*/
export function checkDroidVersion(droidPath: string): void {
try {
const version = execSync(`"${droidPath}" --version`, {
const version = execFileSync(droidPath, ['--version'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
+5 -1
View File
@@ -59,7 +59,11 @@ export interface TargetAdapter {
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;
exec(
args: string[],
env: NodeJS.ProcessEnv,
options?: { cwd?: string; binaryInfo?: TargetBinaryInfo }
): void;
/** Check if a profile type is supported by this target */
supportsProfileType(profileType: string): boolean;
+66 -18
View File
@@ -24,6 +24,64 @@ const ARGV0_TARGET_MAP: Record<string, TargetType> = {
*/
const VALID_TARGETS: ReadonlySet<string> = new Set<TargetType>(['claude', 'droid']);
interface ParsedTargetFlags {
targetOverride?: TargetType;
cleanedArgs: string[];
}
function normalizeTargetValue(value: string): TargetType {
const normalized = value.toLowerCase();
if (VALID_TARGETS.has(normalized)) {
return normalized as TargetType;
}
const available = Array.from(VALID_TARGETS).join(', ');
throw new Error(`Unknown target "${value}". Available: ${available}`);
}
/**
* Parse and strip all --target flags from args.
* Supports both "--target value" and "--target=value" forms.
* For repeated flags, last one wins (common CLI precedence behavior).
*/
function parseTargetFlags(args: string[]): ParsedTargetFlags {
const cleanedArgs: string[] = [];
let targetOverride: TargetType | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
// POSIX option terminator: everything after `--` is positional.
if (arg === '--') {
cleanedArgs.push(...args.slice(i));
break;
}
if (arg === '--target') {
const value = args[i + 1];
if (!value || value.startsWith('-')) {
throw new Error('--target requires a value (claude or droid)');
}
targetOverride = normalizeTargetValue(value);
i += 1; // Skip value
continue;
}
if (arg.startsWith('--target=')) {
const value = arg.slice('--target='.length).trim();
if (!value) {
throw new Error('--target requires a value (claude or droid)');
}
targetOverride = normalizeTargetValue(value);
continue;
}
cleanedArgs.push(arg);
}
return { targetOverride, cleanedArgs };
}
/**
* Resolve target type from multiple sources with priority ordering.
*
@@ -35,15 +93,11 @@ export function resolveTargetType(
args: string[],
profileConfig?: { target?: TargetType }
): TargetType {
const parsed = parseTargetFlags(args);
// 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}`);
if (parsed.targetOverride) {
return parsed.targetOverride;
}
// 2. Check per-profile config
@@ -52,9 +106,9 @@ export function resolveTargetType(
}
// 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, '');
// Strip common wrapper extensions for Windows shims/wrappers
const rawBin = path.basename(process.argv[1] || process.argv0 || '');
const binName = rawBin.replace(/\.(cmd|bat|ps1|exe)$/i, '');
const argv0Target = ARGV0_TARGET_MAP[binName];
if (argv0Target) {
return argv0Target;
@@ -69,11 +123,5 @@ export function resolveTargetType(
* 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;
return parseTargetFlags(args).cleanedArgs;
}
+51 -4
View File
@@ -62,7 +62,8 @@ export function execClaude(
envVars: NodeJS.ProcessEnv | null = null
): void {
const isWindows = process.platform === 'win32';
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
const isPowerShellScript = isWindows && /\.ps1$/i.test(claudeCli);
const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli);
// Get WebSearch hook config env vars
const webSearchEnv = getWebSearchHookEnv();
@@ -98,7 +99,17 @@ export function execClaude(
}
let child: ChildProcess;
if (needsShell) {
if (isPowerShellScript) {
child = spawn(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args],
{
stdio: 'inherit',
windowsHide: true,
env,
}
);
} else if (needsShell) {
// When shell needed: concatenate into string to avoid DEP0190 warning
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
child = spawn(cmdString, {
@@ -116,13 +127,49 @@ export function execClaude(
});
}
const forwardSigInt = () => {
if (!child.killed) child.kill('SIGINT');
};
const forwardSigTerm = () => {
if (!child.killed) child.kill('SIGTERM');
};
const forwardSighup = () => {
if (!child.killed) child.kill('SIGHUP');
};
process.on('SIGINT', forwardSigInt);
process.on('SIGTERM', forwardSigTerm);
process.on('SIGHUP', forwardSighup);
const cleanupSignalHandlers = () => {
process.removeListener('SIGINT', forwardSigInt);
process.removeListener('SIGTERM', forwardSigTerm);
process.removeListener('SIGHUP', forwardSighup);
};
child.on('exit', (code, signal) => {
cleanupSignalHandlers();
if (signal) process.kill(process.pid, signal as NodeJS.Signals);
else process.exit(code || 0);
});
child.on('error', async () => {
await ErrorManager.showClaudeNotFound();
child.on('error', async (err: NodeJS.ErrnoException) => {
cleanupSignalHandlers();
if (err.code === 'EACCES') {
console.error(`[X] Claude CLI is not executable: ${claudeCli}`);
console.error(' Check file permissions and executable bit.');
} else if (err.code === 'ENOENT') {
if (isPowerShellScript) {
console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).');
console.error(' Ensure powershell.exe is available in PATH.');
} else if (needsShell) {
console.error('[X] Windows command shell not found for Claude wrapper launch.');
console.error(' Ensure cmd.exe is available and accessible.');
} else {
await ErrorManager.showClaudeNotFound();
}
} else {
console.error(`[X] Failed to start Claude CLI (${claudeCli}): ${err.message}`);
}
process.exit(1);
});
}
+6
View File
@@ -31,6 +31,7 @@ export interface RawUsageEntry {
timestamp: string;
projectPath: string;
version?: string;
target?: string;
}
/** Internal structure matching JSONL assistant entries */
@@ -40,6 +41,7 @@ interface JsonlAssistantEntry {
timestamp: string;
version?: string;
cwd?: string;
target?: string;
message: {
model: string;
usage: {
@@ -95,6 +97,10 @@ export function parseUsageEntry(line: string, projectPath: string): RawUsageEntr
timestamp: assistant.timestamp || new Date().toISOString(),
projectPath,
version: assistant.version,
target:
typeof (entry as { target?: unknown }).target === 'string'
? ((entry as { target?: string }).target as string)
: undefined,
};
} catch {
// Malformed JSON - skip silently
+11 -1
View File
@@ -371,6 +371,10 @@ export function aggregateSessionUsage(
const sessionUsage: SessionUsage[] = [];
for (const [sessionId, sessionEntries] of bySession) {
const orderedEntries = [...sessionEntries].sort((a, b) =>
a.timestamp.localeCompare(b.timestamp)
);
// Aggregate by model
const modelMap = new Map<string, ModelAccumulator>();
const versions = new Set<string>();
@@ -380,8 +384,9 @@ export function aggregateSessionUsage(
let totalCacheRead = 0;
let lastActivity = '';
let projectPath = '';
let target: string | undefined;
for (const entry of sessionEntries) {
for (const entry of orderedEntries) {
const model = entry.model;
const acc = modelMap.get(model) || {
inputTokens: 0,
@@ -415,6 +420,10 @@ export function aggregateSessionUsage(
if (entry.projectPath) {
projectPath = entry.projectPath;
}
if (entry.target) {
target = entry.target;
}
}
// Build model breakdowns
@@ -450,6 +459,7 @@ export function aggregateSessionUsage(
modelsUsed: Array.from(modelMap.keys()),
modelBreakdowns,
source,
target,
});
}
+1
View File
@@ -549,6 +549,7 @@ export async function handleSessions(
cost: Math.round(s.totalCost * 100) / 100,
lastActivity: s.lastActivity,
modelsUsed: s.modelsUsed,
target: s.target || 'claude',
}));
res.json({
@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
registerSession,
unregisterSession,
getProxyStatus,
} from '../../../src/cliproxy/session-tracker';
describe('session-tracker target metadata', () => {
let tmpDir: string;
let originalCcsHome: string | undefined;
const port = 28317;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-session-target-test-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpDir;
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns single target when all sessions share same target', () => {
const s1 = registerSession(port, process.pid, undefined, undefined, 'droid');
const s2 = registerSession(port, process.pid, undefined, undefined, 'droid');
const status = getProxyStatus(port);
expect(status.running).toBe(true);
expect(status.target).toBe('droid');
expect(status.sessionCount).toBe(2);
unregisterSession(s1, port);
unregisterSession(s2, port);
});
it('returns mixed when active sessions use different targets', () => {
const s1 = registerSession(port, process.pid, undefined, undefined, 'claude');
const s2 = registerSession(port, process.pid, undefined, undefined, 'droid');
const status = getProxyStatus(port);
expect(status.running).toBe(true);
expect(status.target).toBe('mixed');
expect(status.sessionCount).toBe(2);
unregisterSession(s1, port);
unregisterSession(s2, port);
});
});
+36
View File
@@ -237,6 +237,42 @@ describe('aggregateSessionUsage', () => {
expect(result[1].sessionId).toBe('old-session');
});
test('propagates target metadata into session usage', () => {
const entries: RawUsageEntry[] = [
createEntry({ sessionId: 'session-A', target: 'droid' }),
createEntry({ sessionId: 'session-B' }),
];
const result = aggregateSessionUsage(entries);
const sessionA = result.find((s) => s.sessionId === 'session-A');
const sessionB = result.find((s) => s.sessionId === 'session-B');
expect(sessionA?.target).toBe('droid');
expect(sessionB?.target).toBeUndefined();
});
test('uses latest timestamp target when a session has mixed targets', () => {
const entries: RawUsageEntry[] = [
createEntry({
sessionId: 'session-A',
timestamp: '2025-12-09T10:00:00.000Z',
target: 'claude',
}),
createEntry({
sessionId: 'session-A',
timestamp: '2025-12-09T12:00:00.000Z',
target: 'droid',
}),
createEntry({
sessionId: 'session-A',
timestamp: '2025-12-09T11:00:00.000Z',
}),
];
const result = aggregateSessionUsage(entries);
expect(result[0].target).toBe('droid');
});
test('returns empty array for no entries', () => {
const result = aggregateSessionUsage([]);
expect(result.length).toBe(0);
+20
View File
@@ -147,6 +147,26 @@ describe('parseUsageEntry', () => {
const result = parseUsageEntry(VALID_ASSISTANT_ENTRY, '/custom/project/path');
expect(result!.projectPath).toBe('/custom/project/path');
});
test('parses string target when present', () => {
const withTarget = JSON.stringify({
...JSON.parse(VALID_ASSISTANT_ENTRY),
target: 'droid',
});
const result = parseUsageEntry(withTarget, '/test');
expect(result).not.toBeNull();
expect(result!.target).toBe('droid');
});
test('ignores non-string target values', () => {
const withNumericTarget = JSON.stringify({
...JSON.parse(VALID_ASSISTANT_ENTRY),
target: 123,
});
const result = parseUsageEntry(withNumericTarget, '/test');
expect(result).not.toBeNull();
expect(result!.target).toBeUndefined();
});
});
// ============================================================================
@@ -109,6 +109,40 @@ describe('droid-config-manager', () => {
expect(settings.customModels[1].displayName).toBe('CCS gemini');
});
it('should preserve user entries with unknown provider strings', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: [
{
model: 'user-model',
displayName: 'My Custom Provider',
baseUrl: 'https://example.invalid',
apiKey: 'user-key',
provider: 'custom-provider',
},
],
})
);
await upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy',
provider: 'anthropic',
});
const settings = JSON.parse(
fs.readFileSync(path.join(factoryDir, 'settings.json'), 'utf8')
);
expect(settings.customModels).toHaveLength(2);
expect(settings.customModels[0].provider).toBe('custom-provider');
expect(settings.customModels[1].displayName).toBe('CCS gemini');
});
it('should write with restricted permissions', async () => {
await upsertCcsModel('test', {
model: 'test-model',
@@ -124,6 +158,23 @@ describe('droid-config-manager', () => {
const otherPerms = stat.mode & 0o077;
expect(otherPerms).toBe(0);
});
it('should reject symlinked temp file path', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(path.join(factoryDir, 'settings.json'), JSON.stringify({ customModels: [] }));
fs.symlinkSync('/tmp', path.join(factoryDir, 'settings.json.tmp'));
await expect(
upsertCcsModel('gemini', {
model: 'claude-opus-4-6',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy-key',
provider: 'anthropic',
})
).rejects.toThrow(/settings\.json\.tmp is a symlink/);
});
});
describe('removeCcsModel', () => {
@@ -192,6 +243,58 @@ describe('droid-config-manager', () => {
const models = await listCcsModels();
expect(models.size).toBe(0);
});
it('should normalize legacy object-map customModels', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: {
gemini: {
model: 'opus',
displayName: 'CCS gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy',
provider: 'anthropic',
},
invalid: {
model: 'x',
baseUrl: 'x',
},
},
})
);
const models = await listCcsModels();
expect(models.size).toBe(1);
expect(models.has('gemini')).toBe(true);
});
it('should ignore malformed customModels entries', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
fs.writeFileSync(
path.join(factoryDir, 'settings.json'),
JSON.stringify({
customModels: [null, 123, 'bad', { displayName: 'CCS ok', model: 'x', baseUrl: 'x', apiKey: 'y', provider: 'anthropic' }],
})
);
const models = await listCcsModels();
expect(models.size).toBe(1);
expect(models.has('ok')).toBe(true);
});
it('should reject symlinked settings file on read', async () => {
const factoryDir = path.join(tmpDir, '.factory');
fs.mkdirSync(factoryDir, { recursive: true });
const target = path.join(factoryDir, 'real-settings.json');
fs.writeFileSync(target, JSON.stringify({ customModels: [] }));
fs.symlinkSync(target, path.join(factoryDir, 'settings.json'));
await expect(listCcsModels()).rejects.toThrow(/settings\.json is a symlink/);
});
});
describe('pruneOrphanedModels', () => {
+53
View File
@@ -0,0 +1,53 @@
/**
* Unit tests for Droid detector edge cases
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { detectDroidCli, checkDroidVersion } from '../../../src/targets/droid-detector';
describe('droid-detector', () => {
let tmpDir: string;
let originalPath: string | undefined;
let originalDroidPath: string | undefined;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-detector-test-'));
originalPath = process.env.PATH;
originalDroidPath = process.env.CCS_DROID_PATH;
process.env.PATH = '';
});
afterEach(() => {
if (originalPath !== undefined) process.env.PATH = originalPath;
else delete process.env.PATH;
if (originalDroidPath !== undefined) process.env.CCS_DROID_PATH = originalDroidPath;
else delete process.env.CCS_DROID_PATH;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('should prefer CCS_DROID_PATH when it points to a file', () => {
const fakeDroid = path.join(tmpDir, 'droid');
fs.writeFileSync(fakeDroid, '#!/bin/sh\necho droid\n');
process.env.CCS_DROID_PATH = fakeDroid;
expect(detectDroidCli()).toBe(fakeDroid);
});
it('should fall back (null) when CCS_DROID_PATH points to directory', () => {
process.env.CCS_DROID_PATH = tmpDir;
expect(detectDroidCli()).toBeNull();
});
it('should fall back (null) when CCS_DROID_PATH does not exist', () => {
process.env.CCS_DROID_PATH = path.join(tmpDir, 'missing-droid');
expect(detectDroidCli()).toBeNull();
});
it('checkDroidVersion should be non-throwing for invalid binaries', () => {
expect(() => checkDroidVersion(path.join(tmpDir, 'missing-droid'))).not.toThrow();
});
});
+49 -3
View File
@@ -2,6 +2,9 @@
* Unit tests for target registry and adapters
*/
import { describe, it, expect, beforeEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
registerTarget,
getTarget,
@@ -112,11 +115,14 @@ describe('DroidAdapter', () => {
expect(adapter.supportsProfileType('account')).toBe(false);
});
it('should support non-account profile types', () => {
it('should support settings and default profile types', () => {
expect(adapter.supportsProfileType('settings')).toBe(true);
expect(adapter.supportsProfileType('cliproxy')).toBe(true);
expect(adapter.supportsProfileType('default')).toBe(true);
expect(adapter.supportsProfileType('copilot')).toBe(true);
});
it('should NOT support cliproxy and copilot profile types', () => {
expect(adapter.supportsProfileType('cliproxy')).toBe(false);
expect(adapter.supportsProfileType('copilot')).toBe(false);
});
it('should build args with -m custom:ccs- prefix', () => {
@@ -137,4 +143,44 @@ describe('DroidAdapter', () => {
expect(env['ANTHROPIC_BASE_URL']).toBeUndefined();
expect(env['ANTHROPIC_AUTH_TOKEN']).toBeUndefined();
});
it('prepareCredentials should reject missing required credentials', async () => {
await expect(
adapter.prepareCredentials({
profile: 'gemini',
baseUrl: '',
apiKey: 'dummy',
})
).rejects.toThrow(/ANTHROPIC_BASE_URL/);
await expect(
adapter.prepareCredentials({
profile: 'gemini',
baseUrl: 'http://localhost:8317',
apiKey: '',
})
).rejects.toThrow(/ANTHROPIC_AUTH_TOKEN/);
});
it('prepareCredentials should persist valid credentials', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-droid-adapter-test-'));
const originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpDir;
try {
await adapter.prepareCredentials({
profile: 'gemini',
baseUrl: 'http://localhost:8317',
apiKey: 'dummy-key',
model: 'claude-opus-4-6',
});
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
expect(fs.existsSync(settingsPath)).toBe(true);
} finally {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
+70 -4
View File
@@ -51,11 +51,19 @@ describe('resolveTargetType', () => {
expect(resolveTargetType([])).toBe('droid');
});
it('should not match ccsd with .exe extension', () => {
// .exe is not stripped — ccsd.exe won't match 'ccsd' in the map
// This is intentional — npm creates .cmd shims, not .exe
it('should strip .ps1 extension on Windows argv[0]', () => {
process.argv = ['node', 'ccsd.ps1'];
expect(resolveTargetType([])).toBe('droid');
});
it('should strip .exe extension on Windows argv[0]', () => {
process.argv = ['node', 'ccsd.exe'];
expect(resolveTargetType([])).toBe('claude');
expect(resolveTargetType([])).toBe('droid');
});
it('should handle full path argv[0]', () => {
process.argv = ['node', '/usr/local/bin/ccsd'];
expect(resolveTargetType([])).toBe('droid');
});
it('should prioritize --target over argv[0]', () => {
@@ -72,6 +80,31 @@ describe('resolveTargetType', () => {
process.argv = ['node', 'ccs'];
expect(() => resolveTargetType(['--target', 'invalid'])).toThrow(/Unknown target "invalid"/);
});
it('should support --target=<value> form', () => {
process.argv = ['node', 'ccs'];
expect(resolveTargetType(['--target=droid'])).toBe('droid');
});
it('should throw when --target is missing value', () => {
process.argv = ['node', 'ccs'];
expect(() => resolveTargetType(['--target'])).toThrow(/--target requires a value/);
});
it('should throw when --target value is another flag', () => {
process.argv = ['node', 'ccs'];
expect(() => resolveTargetType(['--target', '--help'])).toThrow(/--target requires a value/);
});
it('should use last --target flag when repeated', () => {
process.argv = ['node', 'ccs'];
expect(resolveTargetType(['--target', 'droid', '--target=claude'])).toBe('claude');
});
it('should ignore --target after option terminator', () => {
process.argv = ['node', 'ccs'];
expect(resolveTargetType(['--', '--target', 'droid'])).toBe('claude');
});
});
describe('stripTargetFlag', () => {
@@ -88,9 +121,42 @@ describe('stripTargetFlag', () => {
expect(stripTargetFlag(args)).toEqual(['gemini', '-p', 'hello']);
});
it('should remove --target=<value> form', () => {
expect(stripTargetFlag(['gemini', '--target=droid', '--verbose'])).toEqual([
'gemini',
'--verbose',
]);
});
it('should remove repeated --target flags', () => {
expect(stripTargetFlag(['--target', 'droid', 'gemini', '--target=claude', '--verbose'])).toEqual(
['gemini', '--verbose']
);
});
it('should throw when --target has no value', () => {
expect(() => stripTargetFlag(['gemini', '--target'])).toThrow(/--target requires a value/);
});
it('should throw when --target value is another flag', () => {
expect(() => stripTargetFlag(['gemini', '--target', '--help'])).toThrow(
/--target requires a value/
);
});
it('should not modify the original array', () => {
const args = ['--target', 'droid', 'gemini'];
stripTargetFlag(args);
expect(args).toEqual(['--target', 'droid', 'gemini']);
});
it('should preserve args after option terminator', () => {
expect(stripTargetFlag(['glm', '--', '--target', 'droid', '-p'])).toEqual([
'glm',
'--',
'--target',
'droid',
'-p',
]);
});
});