From 5963ba973e8fd2a9deef26a713159e4af0d3670e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Feb 2026 22:58:36 +0700 Subject: [PATCH] fix(droid): add structural exec passthrough for ccsd --- README.md | 11 ++ src/ccs.ts | 46 +++-- src/commands/help-command.ts | 12 ++ src/targets/droid-command-router.ts | 159 ++++++++++++++++++ .../unit/targets/droid-command-router.test.ts | 86 ++++++++++ 5 files changed, 296 insertions(+), 18 deletions(-) create mode 100644 src/targets/droid-command-router.ts create mode 100644 tests/unit/targets/droid-command-router.test.ts diff --git a/README.md b/README.md index 80c78858..63947638 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,16 @@ CCS also persists Droid's active model selector in `~/.factory/settings.json` (`model: custom:`). This avoids passing `-m` argv in interactive mode, which Droid treats as queued prompt text. +CCS supports structural Droid command passthrough after profile selection: + +```bash +ccsd codex exec --skip-permissions-unsafe "fix failing tests" +ccsd codex --skip-permissions-unsafe "fix failing tests" # auto-routed to: droid exec ... +``` + +If you pass exec-only flags without a prompt (for example `--skip-permissions-unsafe`), +Droid `exec` will return its native "No prompt provided" usage guidance. + Dashboard parity: `ccs config` -> `Factory Droid` ### Per-Profile Target Defaults @@ -187,6 +197,7 @@ Built-in CLIProxy providers also work with Droid alias/target override: ccsd codex ccsd agy ccs codex --target droid +ccsd codex exec --auto high "triage this bug report" ``` Dashboard parity: diff --git a/src/ccs.ts b/src/ccs.ts index f3b24d56..654c5267 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -62,6 +62,7 @@ import { DroidReasoningFlagError, resolveDroidReasoningRuntime, } from './targets/droid-reasoning-runtime'; +import { routeDroidCommandArgs } from './targets/droid-command-router'; // Version and Update check utilities import { getVersion } from './utils/version'; @@ -718,26 +719,35 @@ async function main(): Promise { let targetRemainingArgs = remainingArgs; let droidReasoningOverride: string | number | undefined; if (resolvedTarget === 'droid') { - try { - const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING); - targetRemainingArgs = runtime.argsWithoutReasoningFlags; - droidReasoningOverride = runtime.reasoningOverride; + const droidRoute = routeDroidCommandArgs(remainingArgs); + targetRemainingArgs = droidRoute.argsForDroid; - if (runtime.duplicateDisplays.length > 0) { - console.error( - warn( - `[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || ''}` - ) - ); + if (droidRoute.mode === 'interactive') { + try { + const runtime = resolveDroidReasoningRuntime(remainingArgs, process.env.CCS_THINKING); + targetRemainingArgs = runtime.argsWithoutReasoningFlags; + droidReasoningOverride = runtime.reasoningOverride; + + if (runtime.duplicateDisplays.length > 0) { + console.error( + warn( + `[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || ''}` + ) + ); + } + } catch (error) { + if (error instanceof DroidReasoningFlagError) { + console.error(fail(error.message)); + console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); + console.error(' Codex alias: --effort medium|high|xhigh'); + process.exit(1); + } + throw error; } - } catch (error) { - if (error instanceof DroidReasoningFlagError) { - console.error(fail(error.message)); - console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); - console.error(' Codex alias: --effort medium|high|xhigh'); - process.exit(1); - } - throw error; + } else if (droidRoute.autoPrependedExec && process.stdout.isTTY) { + console.error( + info('Detected Droid exec-only flags. Routing as: droid exec [prompt]') + ); } } diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 9c8561b5..919525a5 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -338,6 +338,14 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccsd glm', 'Same as above (alias)'], ['ccsd codex', 'Run built-in CLIProxy Codex profile on Droid'], ['ccsd agy', 'Run built-in CLIProxy Antigravity profile on Droid'], + [ + 'ccsd codex exec --skip-permissions-unsafe "fix failing tests"', + 'Pass through Droid exec mode', + ], + [ + 'ccsd codex --skip-permissions-unsafe "fix failing tests"', + 'Auto-routes to Droid exec when exec-only flags are detected', + ], [ 'ccs cliproxy create my-codex --provider codex --target droid', 'Create CLIProxy variant with Droid as default target', @@ -393,6 +401,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['--effort ', 'Codex alias for reasoning effort (medium/high/xhigh)'], ['--effort xhigh', 'Pin Codex effort to xhigh for this run'], ['', ''], + ['Droid exec:', 'Use native Droid flag: --reasoning-effort '], + ['', 'CCS auto-maps --thinking/--effort to --reasoning-effort in droid exec mode.'], + ['', 'For interactive droid sessions, CCS applies reasoning via Droid BYOK model config.'], + ['', ''], ['Note:', 'Extended thinking allocates compute for step-by-step reasoning'], ['', 'before responding.'], ['', 'Providers: agy/gemini use --thinking, codex uses --effort (or --thinking alias).'], diff --git a/src/targets/droid-command-router.ts b/src/targets/droid-command-router.ts new file mode 100644 index 00000000..74d6962c --- /dev/null +++ b/src/targets/droid-command-router.ts @@ -0,0 +1,159 @@ +/** + * Droid command router + * + * Determines whether profile args should launch Droid interactive mode + * (`droid [prompt...]`) or command mode (`droid ...`). + * + * Also normalizes CCS legacy reasoning aliases for `droid exec`: + * - --effort / --thinking -> --reasoning-effort + */ + +export type DroidCommandMode = 'interactive' | 'command'; + +export interface DroidCommandRoute { + mode: DroidCommandMode; + argsForDroid: string[]; + command?: string; + autoPrependedExec: boolean; +} + +const DROID_SUBCOMMANDS = new Set([ + 'exec', + 'mcp', + 'plugin', + 'daemon', + 'search', + 'find', + 'ssh', + 'computer', + 'update', + 'help', +]); + +// Exec-only long flags from Factory Droid CLI help. +const DROID_EXEC_ONLY_LONG_FLAGS = new Set([ + '--output-format', + '--input-format', + '--file', + '--auto', + '--skip-permissions-unsafe', + '--session-id', + '--model', + '--reasoning-effort', + '--enabled-tools', + '--disabled-tools', + '--cwd', + '--tag', + '--log-group-id', + '--list-tools', +]); + +function getLongFlagToken(arg: string): string { + const eqIndex = arg.indexOf('='); + return eqIndex >= 0 ? arg.slice(0, eqIndex) : arg; +} + +function isExplicitSubcommand(arg: string | undefined): boolean { + return !!arg && DROID_SUBCOMMANDS.has(arg); +} + +function hasExecOnlyFlagsAtFront(args: string[]): boolean { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--') return false; + + // CCS legacy aliases may appear before exec-only flags; skip their values when present. + if (arg === '--effort' || arg === '--thinking') { + const possibleValue = args[i + 1]; + if (possibleValue && !possibleValue.startsWith('-')) { + i += 1; + } + continue; + } + if (arg.startsWith('--effort=') || arg.startsWith('--thinking=')) { + continue; + } + + if (!arg.startsWith('-')) return false; + if (!arg.startsWith('--')) continue; // short flags are ambiguous at root (`-r` is resume) + + const flagToken = getLongFlagToken(arg); + if (DROID_EXEC_ONLY_LONG_FLAGS.has(flagToken)) { + return true; + } + } + + return false; +} + +function normalizeLegacyReasoningAliasesForExec(args: string[]): string[] { + const normalized: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--effort' || arg === '--thinking') { + const value = args[i + 1]; + if (value && !value.startsWith('-')) { + normalized.push('--reasoning-effort', value); + i += 1; + } else { + // Keep invalid/missing-value form so Droid can surface native validation. + normalized.push(arg); + } + continue; + } + + if (arg.startsWith('--effort=')) { + normalized.push(`--reasoning-effort=${arg.slice('--effort='.length)}`); + continue; + } + + if (arg.startsWith('--thinking=')) { + normalized.push(`--reasoning-effort=${arg.slice('--thinking='.length)}`); + continue; + } + + normalized.push(arg); + } + + return normalized; +} + +export function routeDroidCommandArgs(args: string[]): DroidCommandRoute { + if (args.length === 0) { + return { + mode: 'interactive', + argsForDroid: [], + autoPrependedExec: false, + }; + } + + if (isExplicitSubcommand(args[0])) { + const command = args[0]; + const argsForDroid = + command === 'exec' ? normalizeLegacyReasoningAliasesForExec(args) : [...args]; + return { + mode: 'command', + command, + argsForDroid, + autoPrependedExec: false, + }; + } + + if (hasExecOnlyFlagsAtFront(args)) { + const argsWithExec = ['exec', ...args]; + return { + mode: 'command', + command: 'exec', + argsForDroid: normalizeLegacyReasoningAliasesForExec(argsWithExec), + autoPrependedExec: true, + }; + } + + return { + mode: 'interactive', + argsForDroid: [...args], + autoPrependedExec: false, + }; +} diff --git a/tests/unit/targets/droid-command-router.test.ts b/tests/unit/targets/droid-command-router.test.ts new file mode 100644 index 00000000..0910e102 --- /dev/null +++ b/tests/unit/targets/droid-command-router.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'bun:test'; +import { routeDroidCommandArgs } from '../../../src/targets/droid-command-router'; + +describe('droid-command-router', () => { + it('keeps interactive mode for plain profile launches', () => { + const route = routeDroidCommandArgs([]); + + expect(route.mode).toBe('interactive'); + expect(route.argsForDroid).toEqual([]); + expect(route.autoPrependedExec).toBe(false); + }); + + it('keeps explicit droid subcommands untouched', () => { + const route = routeDroidCommandArgs(['mcp', '--help']); + + expect(route.mode).toBe('command'); + expect(route.command).toBe('mcp'); + expect(route.argsForDroid).toEqual(['mcp', '--help']); + expect(route.autoPrependedExec).toBe(false); + }); + + it('auto-prepends exec for exec-only flags provided after profile', () => { + const route = routeDroidCommandArgs(['--skip-permissions-unsafe']); + + expect(route.mode).toBe('command'); + expect(route.command).toBe('exec'); + expect(route.argsForDroid).toEqual(['exec', '--skip-permissions-unsafe']); + expect(route.autoPrependedExec).toBe(true); + }); + + it('does not auto-prepend exec for root help flag', () => { + const route = routeDroidCommandArgs(['--help']); + + expect(route.mode).toBe('interactive'); + expect(route.argsForDroid).toEqual(['--help']); + expect(route.autoPrependedExec).toBe(false); + }); + + it('normalizes --effort alias to --reasoning-effort for explicit exec', () => { + const route = routeDroidCommandArgs(['exec', '--effort', 'xhigh', 'fix test flake']); + + expect(route.mode).toBe('command'); + expect(route.command).toBe('exec'); + expect(route.argsForDroid).toEqual([ + 'exec', + '--reasoning-effort', + 'xhigh', + 'fix test flake', + ]); + }); + + it('normalizes --thinking alias when exec is auto-prepended', () => { + const route = routeDroidCommandArgs(['--auto', 'high', '--thinking=medium', 'summarize logs']); + + expect(route.mode).toBe('command'); + expect(route.command).toBe('exec'); + expect(route.argsForDroid).toEqual([ + 'exec', + '--auto', + 'high', + '--reasoning-effort=medium', + 'summarize logs', + ]); + expect(route.autoPrependedExec).toBe(true); + }); + + it('still auto-prepends exec when --effort appears before exec-only flags', () => { + const route = routeDroidCommandArgs([ + '--effort', + 'xhigh', + '--skip-permissions-unsafe', + 'fix flaky test', + ]); + + expect(route.mode).toBe('command'); + expect(route.command).toBe('exec'); + expect(route.argsForDroid).toEqual([ + 'exec', + '--reasoning-effort', + 'xhigh', + '--skip-permissions-unsafe', + 'fix flaky test', + ]); + expect(route.autoPrependedExec).toBe(true); + }); +});