mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
fix(dispatcher): preserve --permission-mode for claude agents subcommand
`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 <profile> 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.
This commit is contained in:
@@ -94,6 +94,25 @@ const SUBCOMMAND_SESSION_ONLY_VALUE_FLAGS = new Set<string>([
|
||||
'--teammate-mode',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Flags that look session-only but are actually accepted by specific Claude
|
||||
* subcommands. Keep these intact instead of stripping. Sourced from
|
||||
* `claude <sub> --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 <profile> agents --permission-mode bypassPermissions`.
|
||||
*/
|
||||
const SUBCOMMAND_ALLOWED_SESSION_FLAGS: Record<string, ReadonlySet<string>> = {
|
||||
agents: new Set<string>([
|
||||
'--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<string>([
|
||||
* 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;
|
||||
|
||||
@@ -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 <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', () => {
|
||||
|
||||
Reference in New Issue
Block a user