feat(dispatcher): pass through Claude subcommands without interactive-session args

Closes #1218.

When the user invokes `ccs <profile> agents` (and other Claude subcommands
like `mcp`, `doctor`, `plugin`, ...), CCS was unconditionally injecting
session-only Claude flags (`--append-system-prompt`, `--disallowedTools`,
`--settings`, official-channels plugin specs) and forwarding the
`DISABLE_TELEMETRY=1` env var from profile settings. Each of those
either errored out the subcommand (`error: unknown option
'--append-system-prompt'`) or silently flipped Claude into
non-interactive list mode, so the new `claude agents` agent view never
opened under CCS.

Add a small `claude-subcommand-detector` that recognizes the documented
Claude subcommand set after skipping known value-taking flags, then have
the three steering-prompt injectors (websearch, image-analysis, browser),
the cliproxy and settings launchers, and the official-channels plan
short-circuit when a subcommand invocation is detected. Also strip
`DISABLE_TELEMETRY` from the spawned env only for subcommand
invocations — upstream Claude Code uses that var as a kill switch for
the subcommand TUIs, and the user's telemetry preference still applies
to every normal interactive session.

Verified end-to-end against `ccs glm agents` and `ccs ck agents`: the
agent view opens correctly, no `unknown option` error, and all 1938
existing tests pass.
This commit is contained in:
Tam Nhu Tran
2026-05-12 16:35:37 -04:00
parent 72f2f9993d
commit c2b00b7ad6
9 changed files with 299 additions and 17 deletions
+3
View File
@@ -2,6 +2,7 @@ import {
hasExactFlagValue as hasExactClaudeFlagValue,
splitArgsAtTerminator as splitClaudeArgsAtTerminator,
} from '../claude-tool-args';
import { isClaudeSubcommandInvocation } from '../claude-subcommand-detector';
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
const BROWSER_STEERING_PROMPT =
@@ -18,5 +19,7 @@ function ensureBrowserSteeringPrompt(args: string[]): string[] {
}
export function appendBrowserToolArgs(args: string[]): string[] {
// Subcommands reject `--append-system-prompt`. Issue #1218.
if (isClaudeSubcommandInvocation(args)) return args;
return ensureBrowserSteeringPrompt(args);
}
+149
View File
@@ -0,0 +1,149 @@
/**
* Claude subcommand detection.
*
* Claude Code's CLI accepts both an interactive session form (`claude [prompt]`,
* possibly with `--print`) and explicit subcommands (`claude agents`,
* `claude doctor`, `claude mcp`, ...). Subcommand parsers reject most top-level
* session flags — e.g. `claude agents --append-system-prompt foo` exits with
* `error: unknown option '--append-system-prompt'`.
*
* CCS injects WebSearch / image-analysis / browser steering args
* (`--append-system-prompt`, `--disallowedTools`) for interactive sessions.
* Those flags must be skipped when the user is actually invoking a Claude
* subcommand, otherwise the subcommand fails or falls back to non-interactive
* mode (e.g. `claude agents` printing the list instead of opening the agent
* view — see issue #1218).
*
* Detection walks args left-to-right, skipping known value-taking top-level
* flags, and reports whether the first positional token matches a known
* Claude subcommand.
*/
/**
* Known Claude CLI subcommands. Sourced from `claude --help` (v2.1.139).
* Keep in sync with upstream — additions are safe (over-skipping injection
* for an unknown command is acceptable; under-skipping is the bug).
*/
const CLAUDE_SUBCOMMANDS = new Set<string>([
'agents',
'auth',
'auto-mode',
'doctor',
'install',
'mcp',
'plugin',
'plugins',
'project',
'setup-token',
'ultrareview',
'update',
'upgrade',
]);
/**
* Top-level Claude flags that consume the next argv token as their value.
* Used so the detector doesn't mistake a flag value (e.g. `--name auth`) for
* a subcommand. Boolean flags are intentionally absent.
*
* Variadic flags (`--add-dir`, `--mcp-config`, etc.) only consume their
* immediate next token here; Commander.js' real variadic parsing isn't worth
* replicating since the goal is just to skip past one obvious value safely.
*/
const VALUE_TAKING_FLAGS = new Set<string>([
'--add-dir',
'--agent',
'--agents',
'--allowedTools',
'--allowed-tools',
'--append-system-prompt',
'--betas',
'--channels',
'--debug-file',
'--disallowedTools',
'--disallowed-tools',
'--effort',
'--fallback-model',
'--file',
'--input-format',
'--json-schema',
'--max-budget-usd',
'--mcp-config',
'--model',
'--name',
'-n',
'--output-format',
'--permission-mode',
'--plugin-dir',
'--plugin-url',
'--remote-control-session-name-prefix',
'--session-id',
'--setting-sources',
'--settings',
'--system-prompt',
'--tools',
]);
/**
* Returns true when `args` look like a Claude subcommand invocation.
*
* Heuristic:
* 1. Walk args until the `--` terminator or end.
* 2. Skip known value-taking flags together with their next token.
* 3. Skip unknown `--flag=value` forms and bare `--flag` / `-x` tokens.
* 4. The first remaining positional token is the candidate.
* 5. Match against CLAUDE_SUBCOMMANDS.
*
* Anything after the candidate is irrelevant — once a subcommand is in play,
* the rest of the line belongs to that subcommand.
*/
export function isClaudeSubcommandInvocation(args: readonly string[]): boolean {
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === '--') return false;
if (arg.startsWith('-')) {
if (VALUE_TAKING_FLAGS.has(arg)) {
// Skip the next token as the flag's value (when present and not another flag).
const next = args[i + 1];
if (next !== undefined && !next.startsWith('-')) {
i += 1;
}
}
// `--flag=value`, bare boolean flags, and unknown short/long flags fall through.
continue;
}
return CLAUDE_SUBCOMMANDS.has(arg);
}
return false;
}
/**
* Claude env vars that disable interactive subcommand TUIs (e.g. the new
* `claude agents` agent view) when set. CCS injects these as defaults to
* silence telemetry/bug-report prompts in normal sessions, but they trip
* subcommands into non-interactive list mode. Issue #1218.
*
* Strip only for confirmed Claude subcommand invocations — keep the user's
* telemetry preference intact for everything else.
*/
const SUBCOMMAND_BLOCKING_ENV_KEYS = ['DISABLE_TELEMETRY'] as const;
/**
* Return a shallow copy of `env` with subcommand-blocking telemetry vars
* removed. Caller is responsible for only invoking this when args are a
* Claude subcommand invocation.
*/
export function stripSubcommandBlockingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const out: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(env)) {
if (
SUBCOMMAND_BLOCKING_ENV_KEYS.includes(key as (typeof SUBCOMMAND_BLOCKING_ENV_KEYS)[number])
) {
continue;
}
out[key] = value;
}
return out;
}
@@ -14,6 +14,7 @@ import {
hasManagedPromptFileArg,
PROMPT_FLAG_INLINE,
} from '../prompt-injection-strategy';
import { isClaudeSubcommandInvocation } from '../claude-subcommand-detector';
const IMAGE_ANALYSIS_STEERING_PROMPT = {
name: 'ccs-prompt-image-analysis-tool',
@@ -46,6 +47,8 @@ function ensureImageAnalysisSteeringPrompt(args: string[]): string[] {
}
export function appendThirdPartyImageAnalysisToolArgs(args: string[]): string[] {
// Subcommands reject session-only prompt flags. Issue #1218.
if (isClaudeSubcommandInvocation(args)) return args;
return ensureImageAnalysisSteeringPrompt(args);
}
+13 -1
View File
@@ -8,6 +8,10 @@ import { spawn, spawnSync, ChildProcess, type SpawnOptions } from 'child_process
import { ErrorManager } from './error-manager';
import { getWebSearchHookEnv } from './websearch-manager';
import { wireChildProcessSignals } from './signal-forwarder';
import {
isClaudeSubcommandInvocation,
stripSubcommandBlockingEnv,
} from './claude-subcommand-detector';
import SharedManager from '../management/shared-manager';
import { loadOrCreateUnifiedConfig } from '../config/config-loader-facade';
@@ -263,7 +267,15 @@ export function execClaude(
// Strip Claude Code nested session guard env var to allow CCS delegation
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
const env = stripClaudeCodeEnv(effectiveMergedEnv);
let env = stripClaudeCodeEnv(effectiveMergedEnv);
// For Claude subcommand invocations (`agents`, `mcp`, `doctor`, ...) strip
// telemetry-disable env vars that cause upstream Claude Code to fall back
// to non-interactive list mode instead of opening the subcommand TUI.
// Issue #1218.
if (isClaudeSubcommandInvocation(args)) {
env = stripSubcommandBlockingEnv(env);
}
if (profileType !== 'account') {
try {
+4
View File
@@ -15,6 +15,7 @@ import {
hasManagedPromptFileArg,
PROMPT_FLAG_INLINE,
} from '../prompt-injection-strategy';
import { isClaudeSubcommandInvocation } from '../claude-subcommand-detector';
const NATIVE_WEBSEARCH_TOOL = 'WebSearch';
const DISALLOWED_TOOLS_FLAG = '--disallowedTools';
@@ -135,5 +136,8 @@ function ensureWebSearchSteeringPrompt(args: string[]): string[] {
}
export function appendThirdPartyWebSearchToolArgs(args: string[]): string[] {
// Claude subcommands (agents, doctor, mcp, ...) reject top-level session flags
// like `--append-system-prompt` and `--disallowedTools`. Issue #1218.
if (isClaudeSubcommandInvocation(args)) return args;
return ensureWebSearchSteeringPrompt(ensureDisallowedNativeWebSearchTool(args));
}