From ef4b746876412cafaddb8745d243fb31887abcde Mon Sep 17 00:00:00 2001 From: SoungRyoul Kim Date: Thu, 21 May 2026 20:53:57 +0900 Subject: [PATCH] fix(dispatcher): preserve --permission-mode for `claude agents` subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stripClaudeSubcommandSessionArgs` was unconditionally stripping `--permission-mode`, `--dangerously-skip-permissions`, and `--allow-dangerously-skip-permissions` from every Claude subcommand invocation. That's correct for `auth`/`doctor`/`mcp`/etc. — their parsers reject those flags with `error: unknown option '...'`. But `claude agents` does accept all three (see `claude agents --help`): they configure the default permission mode for sessions dispatched from the agent view. As a result, `ccs agents --permission-mode bypassPermissions` silently dropped the flag before forwarding to Claude and the agent view kept its previous default. Add a small per-subcommand allowlist (`SUBCOMMAND_ALLOWED_SESSION_FLAGS`) and a `getClaudeSubcommandName(args)` helper, then branch the strip logic on the detected subcommand. `agents` keeps the three permission flags; every other subcommand (including unknown future ones) falls through to the existing strip behavior. `--teammate-mode` is still stripped for every subcommand — no upstream subcommand accepts it. Tests cover both before- and after-position arguments, the `--flag=value` form, the `--teammate-mode` exclusion for `agents`, and regression coverage for non-`agents` subcommands. --- src/utils/claude-subcommand-detector.ts | 58 ++++++++++++++++--- .../utils/claude-subcommand-detector.test.ts | 58 +++++++++++++++++-- 2 files changed, 103 insertions(+), 13 deletions(-) diff --git a/src/utils/claude-subcommand-detector.ts b/src/utils/claude-subcommand-detector.ts index dac5b7fa..b02c064a 100644 --- a/src/utils/claude-subcommand-detector.ts +++ b/src/utils/claude-subcommand-detector.ts @@ -94,6 +94,25 @@ const SUBCOMMAND_SESSION_ONLY_VALUE_FLAGS = new Set([ '--teammate-mode', ]); +/** + * Flags that look session-only but are actually accepted by specific Claude + * subcommands. Keep these intact instead of stripping. Sourced from + * `claude --help` (v2.1.139); add new entries when upstream extends a + * subcommand parser. + * + * - `agents` accepts `--permission-mode`, `--dangerously-skip-permissions`, + * and `--allow-dangerously-skip-permissions` as defaults for dispatched + * sessions (see `claude agents --help`). Stripping these breaks + * `ccs agents --permission-mode bypassPermissions`. + */ +const SUBCOMMAND_ALLOWED_SESSION_FLAGS: Record> = { + agents: new Set([ + '--allow-dangerously-skip-permissions', + '--dangerously-skip-permissions', + '--permission-mode', + ]), +}; + /** * Returns true when `args` look like a Claude subcommand invocation. * @@ -108,9 +127,19 @@ const SUBCOMMAND_SESSION_ONLY_VALUE_FLAGS = new Set([ * the rest of the line belongs to that subcommand. */ export function isClaudeSubcommandInvocation(args: readonly string[]): boolean { + return getClaudeSubcommandName(args) !== null; +} + +/** + * Returns the Claude subcommand name when `args` look like a subcommand + * invocation, otherwise null. Uses the same scan as + * `isClaudeSubcommandInvocation` so callers can branch on the specific + * subcommand (e.g. `agents` allows flags that other subcommands reject). + */ +export function getClaudeSubcommandName(args: readonly string[]): string | null { for (let i = 0; i < args.length; i += 1) { const arg = args[i]; - if (arg === '--') return false; + if (arg === '--') return null; if (arg.startsWith('-')) { if (VALUE_TAKING_FLAGS.has(arg)) { @@ -124,29 +153,44 @@ export function isClaudeSubcommandInvocation(args: readonly string[]): boolean { continue; } - return CLAUDE_SUBCOMMANDS.has(arg); + return CLAUDE_SUBCOMMANDS.has(arg) ? arg : null; } - return false; + return null; } export function stripClaudeSubcommandSessionArgs(args: readonly string[]): string[] { - if (!isClaudeSubcommandInvocation(args)) { + const subcommand = getClaudeSubcommandName(args); + if (subcommand === null) { return [...args]; } + const allowed = SUBCOMMAND_ALLOWED_SESSION_FLAGS[subcommand]; + const isAllowed = (flag: string): boolean => allowed?.has(flag) === true; + const out: string[] = []; for (let i = 0; i < args.length; i += 1) { const arg = args[i]; - if (SUBCOMMAND_SESSION_ONLY_FLAGS.has(arg)) { + if (SUBCOMMAND_SESSION_ONLY_FLAGS.has(arg) && !isAllowed(arg)) { continue; } - if (arg.startsWith('--permission-mode=') || arg.startsWith('--teammate-mode=')) { + if (arg.startsWith('--permission-mode=')) { + if (isAllowed('--permission-mode')) { + out.push(arg); + continue; + } + continue; + } + if (arg.startsWith('--teammate-mode=')) { + if (isAllowed('--teammate-mode')) { + out.push(arg); + continue; + } continue; } - if (SUBCOMMAND_SESSION_ONLY_VALUE_FLAGS.has(arg)) { + if (SUBCOMMAND_SESSION_ONLY_VALUE_FLAGS.has(arg) && !isAllowed(arg)) { const next = args[i + 1]; if (next !== undefined && !next.startsWith('-')) { i += 1; diff --git a/tests/unit/utils/claude-subcommand-detector.test.ts b/tests/unit/utils/claude-subcommand-detector.test.ts index 866e7604..412e72cf 100644 --- a/tests/unit/utils/claude-subcommand-detector.test.ts +++ b/tests/unit/utils/claude-subcommand-detector.test.ts @@ -86,28 +86,74 @@ describe('stripClaudeCodeFeatureBlockingEnv', () => { }); describe('stripClaudeSubcommandSessionArgs', () => { - it('removes session-only flags before a Claude subcommand', () => { + it('removes session-only flags before a non-agents Claude subcommand', () => { expect( stripClaudeSubcommandSessionArgs([ '--dangerously-skip-permissions', '--teammate-mode', 'in-process', - 'agents', + 'doctor', ]) - ).toEqual(['agents']); + ).toEqual(['doctor']); }); - it('removes session-only flags after a Claude subcommand while preserving subcommand flags', () => { + it('removes session-only flags after a non-agents subcommand while preserving subcommand flags', () => { expect( stripClaudeSubcommandSessionArgs([ - 'agents', + 'mcp', '--dangerously-skip-permissions', '--teammate-mode', 'in-process', '--setting-sources', 'user', ]) - ).toEqual(['agents', '--setting-sources', 'user']); + ).toEqual(['mcp', '--setting-sources', 'user']); + }); + + it('preserves --permission-mode for the agents subcommand (after)', () => { + // `claude agents` accepts `--permission-mode ` as the default + // permission mode for dispatched sessions; CCS must not strip it. + expect( + stripClaudeSubcommandSessionArgs([ + 'agents', + '--permission-mode', + 'bypassPermissions', + '--teammate-mode', + 'in-process', + ]) + ).toEqual(['agents', '--permission-mode', 'bypassPermissions']); + }); + + it('preserves --permission-mode for the agents subcommand (before, --flag=value form)', () => { + expect( + stripClaudeSubcommandSessionArgs(['--permission-mode=bypassPermissions', 'agents']) + ).toEqual(['--permission-mode=bypassPermissions', 'agents']); + }); + + it('preserves --dangerously-skip-permissions and --allow-dangerously-skip-permissions for agents', () => { + expect( + stripClaudeSubcommandSessionArgs([ + '--dangerously-skip-permissions', + '--allow-dangerously-skip-permissions', + 'agents', + ]) + ).toEqual([ + '--dangerously-skip-permissions', + '--allow-dangerously-skip-permissions', + 'agents', + ]); + }); + + it('still strips --teammate-mode for the agents subcommand (not accepted by upstream)', () => { + expect( + stripClaudeSubcommandSessionArgs([ + 'agents', + '--teammate-mode', + 'in-process', + '--permission-mode', + 'bypassPermissions', + ]) + ).toEqual(['agents', '--permission-mode', 'bypassPermissions']); }); it('leaves non-subcommand interactive launches unchanged', () => {