mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
fix(targets): close all remaining multi-target droid edge cases
This commit is contained in:
@@ -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-...');
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user