mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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<ChildP
|
||||
const browserArgs = browserRuntimeEnv
|
||||
? appendBrowserToolArgs(imageAnalysisArgs)
|
||||
: imageAnalysisArgs;
|
||||
const launchArgs = [
|
||||
'--settings',
|
||||
settingsPath,
|
||||
...appendThirdPartyWebSearchToolArgs(browserArgs),
|
||||
];
|
||||
// Claude subcommands (agents, doctor, mcp, ...) don't accept `--settings` —
|
||||
// its presence flips `agents` into list mode instead of opening the
|
||||
// interactive agent view. Provider routing still flows via env vars.
|
||||
// Issue #1218.
|
||||
const isSubcommand = isClaudeSubcommandInvocation(claudeArgs);
|
||||
const launchArgs = isSubcommand
|
||||
? appendThirdPartyWebSearchToolArgs(browserArgs)
|
||||
: ['--settings', settingsPath, ...appendThirdPartyWebSearchToolArgs(browserArgs)];
|
||||
|
||||
// Inject web search trace context into env
|
||||
const traceEnv = createWebSearchTraceContext({
|
||||
@@ -112,7 +119,12 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
|
||||
settingsPath,
|
||||
claudeConfigDir: inheritedClaudeConfigDir,
|
||||
});
|
||||
const tracedEnv = { ...env, ...traceEnv };
|
||||
// Strip telemetry-disable env vars for subcommands; otherwise Claude's
|
||||
// `agents`/`mcp`/... TUIs silently fall back to non-interactive list mode.
|
||||
// Issue #1218.
|
||||
const tracedEnv = isSubcommand
|
||||
? stripSubcommandBlockingEnv({ ...env, ...traceEnv })
|
||||
: { ...env, ...traceEnv };
|
||||
|
||||
// Spawn: Windows .cmd/.bat/.ps1 need shell escaping; all others spawn directly
|
||||
let claude: ChildProcess;
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
normalizeDeprecatedGlmtEnv,
|
||||
} from '../../utils/glmt-deprecation';
|
||||
import { createOpenAICompatLaunchSettings } from '../../utils/openai-compat-launch-settings';
|
||||
import { isClaudeSubcommandInvocation } from '../../utils/claude-subcommand-detector';
|
||||
import {
|
||||
resolveDroidProvider,
|
||||
evaluateTargetRuntimeCompatibility,
|
||||
@@ -367,11 +368,16 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
delete proxyEnv.ANTHROPIC_API_KEY;
|
||||
const launchSettings = createOpenAICompatLaunchSettings(expandedSettingsPath, settings);
|
||||
|
||||
const launchArgs = [
|
||||
'--settings',
|
||||
launchSettings.settingsPath,
|
||||
...appendThirdPartyWebSearchToolArgs(browserArgs),
|
||||
];
|
||||
// Claude subcommands reject `--settings` (it flips `agents` to list mode).
|
||||
// Routing env vars still flow via proxyEnv. Issue #1218.
|
||||
const isSubcommand = isClaudeSubcommandInvocation(browserArgs);
|
||||
const launchArgs = isSubcommand
|
||||
? appendThirdPartyWebSearchToolArgs(browserArgs)
|
||||
: [
|
||||
'--settings',
|
||||
launchSettings.settingsPath,
|
||||
...appendThirdPartyWebSearchToolArgs(browserArgs),
|
||||
];
|
||||
const traceEnv = createWebSearchTraceContext({
|
||||
launcher: 'ccs.settings-profile.proxy',
|
||||
args: launchArgs,
|
||||
@@ -384,11 +390,12 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
return;
|
||||
}
|
||||
|
||||
const launchArgs = [
|
||||
'--settings',
|
||||
expandedSettingsPath,
|
||||
...appendThirdPartyWebSearchToolArgs(browserArgs),
|
||||
];
|
||||
// Skip `--settings` for Claude subcommands so the interactive agent view
|
||||
// works; env vars from the settings file still flow via envVars. Issue #1218.
|
||||
const isSubcommand = isClaudeSubcommandInvocation(browserArgs);
|
||||
const launchArgs = isSubcommand
|
||||
? appendThirdPartyWebSearchToolArgs(browserArgs)
|
||||
: ['--settings', expandedSettingsPath, ...appendThirdPartyWebSearchToolArgs(browserArgs)];
|
||||
const traceEnv = createWebSearchTraceContext({
|
||||
launcher: 'ccs.settings-profile',
|
||||
args: launchArgs,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user