From 3fbf8504718c38c7e675bd68c90c0acf7d13aa6c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 28 May 2026 22:21:26 -0400 Subject: [PATCH] fix: forward bare Claude subcommands through the default profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ccs agents` exited with "Profile not found: agents" instead of opening the Claude agent view. The first positional token was treated as a profile name, so `detectProfileType` threw for any bare Claude subcommand that is not also a profile. `ccs agents` (#1218) and `ccs default agents` already worked; only the bare form was unrouted. On the profile-not-found path, reroute documented Claude subcommands (agents, mcp, plugin, project, setup-token, auto-mode, remote-control, ultrareview, upgrade, install) through the default profile so they launch `claude ` — where the existing launcher already strips interactive-session args. Gated to the claude target so codex/droid keep their own subcommand routing, and only reached when no profile of that name exists, so a real configured profile still wins. CCS commands that shadow Claude subcommands (doctor, update, auth) are intercepted earlier by the root-command router and are unaffected. Closes #1404 --- src/dispatcher/profile-resolver.ts | 45 ++++++++++++++++++- ...le-resolver-subcommand-passthrough.test.ts | 45 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 tests/unit/dispatcher/profile-resolver-subcommand-passthrough.test.ts diff --git a/src/dispatcher/profile-resolver.ts b/src/dispatcher/profile-resolver.ts index dcbbfd9e..9cf6d35d 100644 --- a/src/dispatcher/profile-resolver.ts +++ b/src/dispatcher/profile-resolver.ts @@ -29,6 +29,7 @@ import { resolveTargetType, stripTargetFlag } from '../targets/target-resolver'; import { DroidReasoningFlagError } from '../targets/droid-reasoning-runtime'; import { DroidCommandRouterError, routeDroidCommandArgs } from '../targets/droid-command-router'; import { resolveCliproxyBridgeMetadata } from '../api/services/cliproxy-profile-bridge'; +import { getClaudeSubcommandName } from '../utils/claude-subcommand-detector'; import { resolveCodexRuntimeConfigOverrides } from './environment-builder'; import { detectProfile, @@ -83,6 +84,31 @@ function usesImplicitDefaultProfile(cleanArgs: string[]): boolean { return cleanArgs.length === 0 || cleanArgs[0]?.startsWith('-') === true; } +/** + * Decide whether a first token that has no matching profile is actually a bare + * Claude subcommand that should be forwarded through the default profile. + * + * Claude Code exposes subcommands like `claude agents`, `claude mcp`, + * `claude plugin`, `claude setup-token`. Invoked through CCS as `ccs agents`, + * `ccs mcp`, ... the first token is treated as a profile name, so profile + * resolution throws "profile not found". Instead, forward such tokens to + * `claude ` under the default profile (matching `ccs default + * agents`), where the launcher already strips interactive-session args. + * + * Gated to the claude target — codex/droid run their own subcommand routing — + * and only reached on the profile-not-found path, so a real configured profile + * of the same name always wins. + */ +export function isBareClaudeSubcommandPassthrough(profile: string, args: string[]): boolean { + if (profile === 'default') return false; + if (getClaudeSubcommandName([profile]) === null) return false; + try { + return resolveTargetType(args) === 'claude'; + } catch { + return false; + } +} + function buildNativeCodexDefaultProfile(): ProfileDetectionResult { return { type: 'default', @@ -123,8 +149,23 @@ export async function resolveProfileAndTarget( // Detect profile (strip --target flags before profile detection) const cleanArgs = stripTargetFlag(args); - const { profile, remainingArgs } = detectProfile(cleanArgs); - let profileInfo: ProfileDetectionResult = detector.detectProfileType(profile); + const detected = detectProfile(cleanArgs); + let profile = detected.profile; + let remainingArgs = detected.remainingArgs; + let profileInfo: ProfileDetectionResult; + try { + profileInfo = detector.detectProfileType(profile); + } catch (profileError) { + // Bare Claude subcommand passthrough: forward `ccs agents`, `ccs mcp`, ... + // through the default profile instead of failing as an unknown profile. + if (isBareClaudeSubcommandPassthrough(profile, args)) { + remainingArgs = [profile, ...remainingArgs]; + profile = 'default'; + profileInfo = detector.detectProfileType(profile); + } else { + throw profileError; + } + } let resolvedTarget: ReturnType; try { diff --git a/tests/unit/dispatcher/profile-resolver-subcommand-passthrough.test.ts b/tests/unit/dispatcher/profile-resolver-subcommand-passthrough.test.ts new file mode 100644 index 00000000..4a59d8f6 --- /dev/null +++ b/tests/unit/dispatcher/profile-resolver-subcommand-passthrough.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'bun:test'; +import { isBareClaudeSubcommandPassthrough } from '../../../src/dispatcher/profile-resolver'; + +/** + * Bare Claude subcommand passthrough decision (`ccs agents`, `ccs mcp`, ...). + * + * The predicate is consulted only on the profile-not-found path, so a real + * configured profile of the same name is resolved earlier and never reaches + * this gate. These tests pin the decision matrix: forward documented Claude + * subcommands on the claude target, leave everything else alone. + */ +describe('isBareClaudeSubcommandPassthrough', () => { + it('reroutes a bare Claude subcommand on the default (claude) target', () => { + expect(isBareClaudeSubcommandPassthrough('agents', ['agents'])).toBe(true); + expect(isBareClaudeSubcommandPassthrough('mcp', ['mcp'])).toBe(true); + expect(isBareClaudeSubcommandPassthrough('plugin', ['plugin'])).toBe(true); + expect(isBareClaudeSubcommandPassthrough('setup-token', ['setup-token'])).toBe(true); + }); + + it('reroutes when the subcommand carries its own flags', () => { + expect( + isBareClaudeSubcommandPassthrough('agents', [ + 'agents', + '--permission-mode', + 'bypassPermissions', + ]) + ).toBe(true); + }); + + it('does not reroute the implicit default profile', () => { + expect(isBareClaudeSubcommandPassthrough('default', [])).toBe(false); + }); + + it('does not reroute an unknown non-subcommand token', () => { + expect(isBareClaudeSubcommandPassthrough('notaprofile', ['notaprofile'])).toBe(false); + expect(isBareClaudeSubcommandPassthrough('glm', ['glm'])).toBe(false); + }); + + it('does not reroute when an explicit non-claude target is selected', () => { + expect(isBareClaudeSubcommandPassthrough('agents', ['agents', '--target', 'droid'])).toBe( + false + ); + expect(isBareClaudeSubcommandPassthrough('mcp', ['mcp', '--target', 'codex'])).toBe(false); + }); +});