From 1b5376239fbdd788de90717792297714e8d48ab9 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Thu, 7 May 2026 06:14:12 -0400 Subject: [PATCH] fix: preserve native Claude passthrough args Closes #1189 --- docs/project-roadmap.md | 3 +- src/ccs.ts | 2 +- src/delegation/delegation-handler.ts | 150 ++++++++++---- .../delegation/delegation-handler.test.ts | 194 ++++++++++++++++++ 4 files changed, 311 insertions(+), 38 deletions(-) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 2521db5c..76e0ff07 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-04-30 +Last Updated: 2026-05-07 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-05-07**: **#1189** Headless settings-profile delegation now preserves native Claude passthrough args without a Claude flag allowlist. Explicit `--channels` values reach Claude Code, future native flags can carry multiple adjacent values, malformed CCS-owned flags no longer swallow the next native flag, and `--prompt=` routes through headless delegation consistently with `--prompt `. - **2026-05-03**: **#1172** Local CLIProxy config generation now keeps the CPAMC management dashboard aligned with backend selection. `backend: original` points to the upstream dashboard, `backend: plus` points to the CCS-maintained CPAMC fork, `cliproxy.management_panel_repository` lets advanced users override the panel repository, and stale generated configs are regenerated when the expected panel source changes. - **2026-04-30**: **#1153** Native Claude launches now accept session-scoped `--effort low|medium|high|xhigh|max` overrides through CCS without mutating global Claude settings. CCS validates invalid or missing effort values before spawning Claude, normalizes accepted values, keeps default headless `-p/--prompt` launches on native Claude instead of delegation parsing, and preserves CLIProxy/Codex/Droid effort aliases. - **2026-04-28**: **#1123** CLIProxy quota failover now uses the dashboard/manual pause mechanism for all quota-visible OAuth providers with CCS quota fetchers: Antigravity, Claude, Codex, Gemini CLI, and GitHub Copilot. When a healthy fallback exists, CCS moves the exhausted account token out of the live `auth/` folder into `auth-paused/`, marks the account paused for dashboard visibility, persists the cooldown for auto-resume, and still avoids self-pausing the last usable account. diff --git a/src/ccs.ts b/src/ccs.ts index b1163244..f62909a2 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -73,7 +73,7 @@ async function main(): Promise { // 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')) { + if (args.some((arg) => arg === '-p' || arg === '--prompt' || arg.startsWith('--prompt='))) { const shouldUseDelegation = resolvedTarget === 'claude' && profileInfo.type === 'settings'; if (shouldUseDelegation) { const { DelegationHandler } = await import('./delegation/delegation-handler'); diff --git a/src/delegation/delegation-handler.ts b/src/delegation/delegation-handler.ts index 2716bfcb..4e59ffdf 100644 --- a/src/delegation/delegation-handler.ts +++ b/src/delegation/delegation-handler.ts @@ -9,6 +9,59 @@ import { fail, warn } from '../utils/ui'; import { getCcsDir } from '../config/config-loader-facade'; const PROFILE_FLAGS_WITH_VALUE = new Set(['-p', '--prompt', '--effort']); +const PROMPT_FLAGS_WITH_VALUE = new Set(['-p', '--prompt']); +const NUMERIC_CCS_FLAGS_WITH_VALUE = new Set(['--timeout', '--max-turns']); +const DASH_REJECTING_CCS_FLAGS_WITH_VALUE = new Set([ + '--permission-mode', + '--fallback-model', + '--agents', + '--betas', +]); +const CCS_FLAGS_WITH_VALUE = new Set([ + '-p', + '--prompt', + '--timeout', + '--permission-mode', + '--max-turns', + '--fallback-model', + '--agents', + '--betas', +]); + +function isFlag(arg: string): boolean { + return arg.startsWith('-'); +} + +function isInlineCcsValueFlag(arg: string): boolean { + return Array.from(CCS_FLAGS_WITH_VALUE).some( + (flag) => flag.startsWith('--') && arg.startsWith(`${flag}=`) + ); +} + +function shouldSkipCcsFlagValue(args: string[], index: number): boolean { + const arg = args[index]; + if (index >= args.length - 1) return false; + if (PROMPT_FLAGS_WITH_VALUE.has(arg)) return true; + const nextArg = args[index + 1]; + if (NUMERIC_CCS_FLAGS_WITH_VALUE.has(arg) && /^-\d/.test(nextArg)) return true; + if ( + DASH_REJECTING_CCS_FLAGS_WITH_VALUE.has(arg) && + isFlag(nextArg) && + !nextArg.startsWith('--') && + !CCS_FLAGS_WITH_VALUE.has(nextArg) + ) { + return true; + } + return !isFlag(nextArg); +} + +function findInlineFlagValue(args: string[], flagName: string): string | undefined { + const prefix = `${flagName}=`; + const inlineArg = args.find((arg) => arg.startsWith(prefix)); + if (inlineArg === undefined) return undefined; + const value = inlineArg.slice(prefix.length); + return value.trim().length > 0 ? value : undefined; +} /** * Parse and validate a string flag value @@ -20,9 +73,15 @@ function parseStringFlag( options?: { allowDashPrefix?: boolean } ): string | undefined { const index = args.indexOf(flagName); - if (index === -1 || index >= args.length - 1) return undefined; + let value: string | undefined; - const value = args[index + 1]; + if (index === -1) { + value = findInlineFlagValue(args, flagName); + if (value === undefined) return undefined; + } else { + if (index >= args.length - 1) return undefined; + value = args[index + 1]; + } // Reject dash-prefixed values (likely another flag) if (!options?.allowDashPrefix && value.startsWith('-')) { @@ -165,9 +224,10 @@ export class DelegationHandler { continue; } + if (args[i].startsWith('--prompt=')) continue; if (args[i].startsWith('--effort=')) continue; - if (!args[i].startsWith('-')) { + if (!isFlag(args[i])) { return args[i]; } } @@ -187,6 +247,14 @@ export class DelegationHandler { const index = pIndex !== -1 ? pIndex : promptIndex; + if (index === -1) { + const inlinePrompt = args.find((arg) => arg.startsWith('--prompt=')); + if (inlinePrompt) { + const prompt = inlinePrompt.slice('--prompt='.length); + if (prompt.length > 0) return prompt; + } + } + if (index === -1 || index === args.length - 1) { console.error(fail('Missing prompt after -p flag')); console.error(' Usage: ccs glm -p "task description"'); @@ -215,15 +283,20 @@ export class DelegationHandler { }; // Parse permission-mode (CLI flag overrides settings file) - const permModeIndex = args.indexOf('--permission-mode'); - if (permModeIndex !== -1 && permModeIndex < args.length - 1) { - options.permissionMode = args[permModeIndex + 1]; + const permissionMode = parseStringFlag(args, '--permission-mode'); + if (permissionMode) { + options.permissionMode = permissionMode; } // Parse timeout (validated: positive integer, max 10 minutes) const timeoutIndex = args.indexOf('--timeout'); - if (timeoutIndex !== -1 && timeoutIndex < args.length - 1) { - const rawVal = args[timeoutIndex + 1]; + const inlineTimeout = findInlineFlagValue(args, '--timeout'); + const rawTimeout = + timeoutIndex !== -1 && timeoutIndex < args.length - 1 + ? args[timeoutIndex + 1] + : inlineTimeout; + if (rawTimeout !== undefined) { + const rawVal = rawTimeout; const val = parseInt(rawVal, 10); if (!isNaN(val) && val > 0 && val <= 600000) { options.timeout = val; @@ -238,8 +311,13 @@ export class DelegationHandler { // Parse --max-turns (limit agentic turns, max 100) const maxTurnsIndex = args.indexOf('--max-turns'); - if (maxTurnsIndex !== -1 && maxTurnsIndex < args.length - 1) { - const rawVal = args[maxTurnsIndex + 1]; + const inlineMaxTurns = findInlineFlagValue(args, '--max-turns'); + const rawMaxTurns = + maxTurnsIndex !== -1 && maxTurnsIndex < args.length - 1 + ? args[maxTurnsIndex + 1] + : inlineMaxTurns; + if (rawMaxTurns !== undefined) { + const rawVal = rawMaxTurns; const val = parseInt(rawVal, 10); if (!isNaN(val) && val > 0 && val <= 100) { options.maxTurns = val; @@ -271,42 +349,42 @@ export class DelegationHandler { // Parse --betas (experimental features) options.betas = parseStringFlag(args, '--betas'); - // Collect extra args to pass through to Claude CLI - // CCS-handled flags with values (skip these and their values): - const ccsFlagsWithValue = new Set([ - '-p', - '--prompt', - '--timeout', - '--permission-mode', - '--max-turns', - '--fallback-model', - '--agents', - '--betas', - ]); + // Collect extra args to pass through to Claude CLI. + // Only CCS-owned flags are consumed here. Unknown/native Claude flags are preserved + // generically, including future variadic flags such as "--flag value1 value2". const extraArgs: string[] = []; const profile = this._extractProfile(args); + let profileSkipped = false; for (let i = 0; i < args.length; i++) { const arg = args[i]; - // Skip profile name (non-flag first arg) - if (arg === profile && !arg.startsWith('-')) continue; - - // Skip CCS-handled flags and their values - if (ccsFlagsWithValue.has(arg)) { - i++; // Skip next arg (the value) + // Skip only the first profile token. Later matching values may belong to native flags. + if (!profileSkipped && arg === profile && !isFlag(arg)) { + profileSkipped = true; continue; } - // Collect flags and their values as passthrough - if (arg.startsWith('-')) { - extraArgs.push(arg); - // If next arg exists and doesn't start with '-', it's likely a value - if (i + 1 < args.length && !args[i + 1].startsWith('-')) { - extraArgs.push(args[i + 1]); - i++; // Skip the value we just added - } + // Skip CCS-handled flags and their values. + if (CCS_FLAGS_WITH_VALUE.has(arg)) { + if (shouldSkipCcsFlagValue(args, i)) i++; + continue; } + if (isInlineCcsValueFlag(arg)) { + continue; + } + + // Preserve native/future Claude flags and all adjacent values until the next flag. + if (isFlag(arg)) { + extraArgs.push(arg); + while (i + 1 < args.length && !isFlag(args[i + 1])) { + extraArgs.push(args[i + 1]); + i++; + } + continue; + } + + extraArgs.push(arg); } if (extraArgs.length > 0) { diff --git a/tests/unit/delegation/delegation-handler.test.ts b/tests/unit/delegation/delegation-handler.test.ts index 095a8e8f..ea5fbd2d 100644 --- a/tests/unit/delegation/delegation-handler.test.ts +++ b/tests/unit/delegation/delegation-handler.test.ts @@ -36,6 +36,7 @@ describe('DelegationHandler', () => { it('rejects negative timeout with warning', () => { const options = handler._extractOptions(['glm', '-p', 'test', '--timeout', '-5000']); expect(options.timeout).toBeUndefined(); + expect(options.extraArgs).toBeUndefined(); expect(consoleErrorSpy).toHaveBeenCalled(); }); @@ -55,6 +56,22 @@ describe('DelegationHandler', () => { const options = handler._extractOptions(['glm', '-p', 'test', '--timeout']); expect(options.timeout).toBeUndefined(); }); + + it('does not drop following native flags when timeout value is missing', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--timeout', + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + expect(options.timeout).toBeUndefined(); + expect(options.extraArgs).toEqual([ + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + }); }); describe('_extractOptions - max-turns validation', () => { @@ -72,6 +89,7 @@ describe('DelegationHandler', () => { it('rejects negative max-turns with warning', () => { const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns', '-5']); expect(options.maxTurns).toBeUndefined(); + expect(options.extraArgs).toBeUndefined(); expect(consoleErrorSpy).toHaveBeenCalled(); }); @@ -91,6 +109,18 @@ describe('DelegationHandler', () => { const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns', '100']); expect(options.maxTurns).toBe(100); }); + + it('accepts inline max-turns syntax', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns=7']); + expect(options.maxTurns).toBe(7); + expect(options.extraArgs).toBeUndefined(); + }); + + it('treats empty inline max-turns as missing', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns=']); + expect(options.maxTurns).toBeUndefined(); + expect(options.extraArgs).toBeUndefined(); + }); }); describe('_extractOptions - fallback-model validation', () => { @@ -99,6 +129,18 @@ describe('DelegationHandler', () => { expect(options.fallbackModel).toBe('sonnet'); }); + it('accepts inline fallback-model syntax', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--fallback-model=sonnet']); + expect(options.fallbackModel).toBe('sonnet'); + expect(options.extraArgs).toBeUndefined(); + }); + + it('treats empty inline fallback-model as missing', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--fallback-model=']); + expect(options.fallbackModel).toBeUndefined(); + expect(options.extraArgs).toBeUndefined(); + }); + it('rejects dash-prefixed value with warning', () => { const options = handler._extractOptions([ 'glm', @@ -111,6 +153,24 @@ describe('DelegationHandler', () => { expect(consoleErrorSpy).toHaveBeenCalled(); }); + it('does not forward rejected dash-prefixed fallback-model values', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--fallback-model', + '-sonnet', + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + expect(options.fallbackModel).toBeUndefined(); + expect(options.extraArgs).toEqual([ + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + it('rejects empty string value', () => { const options = handler._extractOptions(['glm', '-p', 'test', '--fallback-model', '']); expect(options.fallbackModel).toBeUndefined(); @@ -122,6 +182,54 @@ describe('DelegationHandler', () => { }); }); + describe('_extractOptions - permission-mode parsing', () => { + it('accepts inline permission-mode syntax', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--permission-mode=plan']); + expect(options.permissionMode).toBe('plan'); + expect(options.extraArgs).toBeUndefined(); + }); + + it('treats empty inline permission-mode as missing', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--permission-mode=']); + expect(options.permissionMode).toBe('acceptEdits'); + expect(options.extraArgs).toBeUndefined(); + }); + + it('does not drop following native flags when permission-mode value is missing', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--permission-mode', + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + expect(options.permissionMode).not.toBe('--channels'); + expect(options.extraArgs).toEqual([ + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + }); + + it('does not forward rejected dash-prefixed permission-mode values', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--permission-mode', + '-invalid', + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + expect(options.permissionMode).toBe('acceptEdits'); + expect(options.extraArgs).toEqual([ + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + }); + describe('_extractOptions - agents JSON validation', () => { it('accepts valid JSON for agents', () => { const options = handler._extractOptions(['glm', '-p', 'test', '--agents', '{"name":"test"}']); @@ -189,6 +297,67 @@ describe('DelegationHandler', () => { const options = handler._extractOptions(['glm', '--effort', 'low', '-p', 'test']); expect(options.extraArgs).toEqual(['--effort', 'low']); }); + + it('passes Claude channels through to extraArgs', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + expect(options.extraArgs).toEqual([ + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + }); + + it('preserves variadic native flag values without a Claude flag allowlist', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--future-native', + 'alpha', + 'beta', + '--another-native', + 'gamma', + ]); + expect(options.extraArgs).toEqual([ + '--future-native', + 'alpha', + 'beta', + '--another-native', + 'gamma', + ]); + }); + + it('keeps native flag values that match the profile name', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--channels', 'glm']); + expect(options.extraArgs).toEqual(['--channels', 'glm']); + }); + + it('does not drop following native flags when a CCS flag value is missing', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--fallback-model', + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + expect(options.fallbackModel).toBeUndefined(); + expect(options.extraArgs).toEqual([ + '--channels', + 'plugin:telegram@claude-plugins-official', + ]); + }); + + it('keeps later CCS prompt flags from leaking when a prior CCS value is invalid', () => { + const options = handler._extractOptions(['glm', '--fallback-model', '-p', 'test']); + expect(options.fallbackModel).toBeUndefined(); + expect(options.extraArgs).toBeUndefined(); + }); }); describe('_extractProfile', () => { @@ -202,6 +371,11 @@ describe('DelegationHandler', () => { expect(profile).toBe(''); }); + it('skips inline prompt before profile names', () => { + const profile = handler._extractProfile(['--prompt=test', 'glm']); + expect(profile).toBe('glm'); + }); + it('skips flag values correctly', () => { const profile = handler._extractProfile(['-p', 'test', 'kimi']); expect(profile).toBe('kimi'); @@ -217,4 +391,24 @@ describe('DelegationHandler', () => { expect(profile).toBe('glm'); }); }); + + describe('_extractPrompt', () => { + it('accepts inline prompt syntax', () => { + const prompt = handler._extractPrompt(['glm', '--prompt=test']); + expect(prompt).toBe('test'); + }); + + it('rejects empty inline prompt syntax', () => { + const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as typeof process.exit); + + try { + expect(() => handler._extractPrompt(['glm', '--prompt='])).toThrow('process.exit(1)'); + expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + exitSpy.mockRestore(); + } + }); + }); });