diff --git a/src/channels/official-channels-runtime.ts b/src/channels/official-channels-runtime.ts index 7b95e19b..9846ef71 100644 --- a/src/channels/official-channels-runtime.ts +++ b/src/channels/official-channels-runtime.ts @@ -9,6 +9,7 @@ import { isClaudeCliVersionAtLeast, type ClaudeAuthStatus, } from '../utils/claude-detector'; +import { isClaudeSubcommandInvocation } from '../utils/claude-subcommand-detector'; export interface OfficialChannelDefinition { id: OfficialChannelId; @@ -337,6 +338,17 @@ export function resolveOfficialChannelsLaunchPlan( }; } + // Claude subcommands (agents, doctor, mcp, ...) don't accept official-channels + // plugin args. Skip silently — channels are session-only. Issue #1218. + if (isClaudeSubcommandInvocation(args)) { + return { + applied: false, + wantsPermissionBypass: false, + appliedChannels: [], + skippedMessages, + }; + } + if (!isDiscordChannelsSessionSupported(target, profileType)) { return { applied: false, diff --git a/src/cliproxy/executor/claude-launcher.ts b/src/cliproxy/executor/claude-launcher.ts index c4f97a13..04766e21 100644 --- a/src/cliproxy/executor/claude-launcher.ts +++ b/src/cliproxy/executor/claude-launcher.ts @@ -24,6 +24,10 @@ import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy'; import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy'; import { setupCleanupHandlers } from './session-bridge'; import { resolveRuntimeQuotaMonitorProviders } from './account-resolution'; +import { + isClaudeSubcommandInvocation, + stripSubcommandBlockingEnv, +} from '../../utils/claude-subcommand-detector'; export interface ClaudeLaunchContext { /** Path to the Claude CLI executable */ @@ -97,11 +101,14 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise([ + '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([ + '--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; +} diff --git a/src/utils/image-analysis/claude-tool-args.ts b/src/utils/image-analysis/claude-tool-args.ts index c4be4df1..c328158b 100644 --- a/src/utils/image-analysis/claude-tool-args.ts +++ b/src/utils/image-analysis/claude-tool-args.ts @@ -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); } diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 62e0af04..37356f28 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -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 { diff --git a/src/utils/websearch/claude-tool-args.ts b/src/utils/websearch/claude-tool-args.ts index a680fedd..0ff85d04 100644 --- a/src/utils/websearch/claude-tool-args.ts +++ b/src/utils/websearch/claude-tool-args.ts @@ -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)); } diff --git a/tests/unit/utils/claude-subcommand-detector.test.ts b/tests/unit/utils/claude-subcommand-detector.test.ts new file mode 100644 index 00000000..de430305 --- /dev/null +++ b/tests/unit/utils/claude-subcommand-detector.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'bun:test'; +import { isClaudeSubcommandInvocation } from '../../../src/utils/claude-subcommand-detector'; +import { appendThirdPartyWebSearchToolArgs } from '../../../src/utils/websearch/claude-tool-args'; +import { appendThirdPartyImageAnalysisToolArgs } from '../../../src/utils/image-analysis/claude-tool-args'; +import { appendBrowserToolArgs } from '../../../src/utils/browser/claude-tool-args'; + +describe('isClaudeSubcommandInvocation', () => { + it('returns false for an empty arg list', () => { + expect(isClaudeSubcommandInvocation([])).toBe(false); + }); + + it('returns false for a prompt-only invocation', () => { + expect(isClaudeSubcommandInvocation(['fix the failing test'])).toBe(false); + }); + + it('detects bare subcommand', () => { + for (const cmd of [ + 'agents', + 'auth', + 'doctor', + 'mcp', + 'plugin', + 'plugins', + 'project', + 'setup-token', + 'ultrareview', + 'update', + 'upgrade', + 'auto-mode', + 'install', + ]) { + expect(isClaudeSubcommandInvocation([cmd])).toBe(true); + } + }); + + it('skips past value-taking flags before the positional', () => { + expect(isClaudeSubcommandInvocation(['--model', 'sonnet', 'agents'])).toBe(true); + expect(isClaudeSubcommandInvocation(['--settings', '/tmp/s.json', 'doctor'])).toBe(true); + }); + + it('does not treat a flag value matching a subcommand name as a subcommand', () => { + // `--name auth` sets the session display name to "auth" — still an interactive launch. + expect(isClaudeSubcommandInvocation(['--name', 'auth'])).toBe(false); + }); + + it('handles --flag=value forms', () => { + expect(isClaudeSubcommandInvocation(['--model=sonnet', 'agents'])).toBe(true); + }); + + it('stops scanning at the -- terminator', () => { + expect(isClaudeSubcommandInvocation(['--', 'agents'])).toBe(false); + }); + + it('ignores subcommand-named tokens that come AFTER the first positional prompt', () => { + // First positional is the prompt; "agents" later is just a word in the prompt context. + expect(isClaudeSubcommandInvocation(['talk to me about', 'agents'])).toBe(false); + }); +}); + +describe('subcommand passthrough — injectors short-circuit', () => { + it('appendThirdPartyWebSearchToolArgs returns args unchanged for subcommand invocations', () => { + expect(appendThirdPartyWebSearchToolArgs(['agents'])).toEqual(['agents']); + expect(appendThirdPartyWebSearchToolArgs(['doctor'])).toEqual(['doctor']); + expect(appendThirdPartyWebSearchToolArgs(['mcp', 'list'])).toEqual(['mcp', 'list']); + }); + + it('appendThirdPartyImageAnalysisToolArgs returns args unchanged for subcommand invocations', () => { + expect(appendThirdPartyImageAnalysisToolArgs(['agents'])).toEqual(['agents']); + }); + + it('appendBrowserToolArgs returns args unchanged for subcommand invocations', () => { + expect(appendBrowserToolArgs(['agents'])).toEqual(['agents']); + }); + + it('injectors still inject for non-subcommand interactive launches', () => { + const out = appendThirdPartyWebSearchToolArgs(['fix the bug']); + expect(out).toContain('--append-system-prompt'); + expect(out).toContain('--disallowedTools'); + }); +});