mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 14:19:56 +00:00
fix: forward bare Claude subcommands through the default profile
`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 <profile> 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 <subcommand>` — 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
This commit is contained in:
@@ -29,6 +29,7 @@ import { resolveTargetType, stripTargetFlag } from '../targets/target-resolver';
|
|||||||
import { DroidReasoningFlagError } from '../targets/droid-reasoning-runtime';
|
import { DroidReasoningFlagError } from '../targets/droid-reasoning-runtime';
|
||||||
import { DroidCommandRouterError, routeDroidCommandArgs } from '../targets/droid-command-router';
|
import { DroidCommandRouterError, routeDroidCommandArgs } from '../targets/droid-command-router';
|
||||||
import { resolveCliproxyBridgeMetadata } from '../api/services/cliproxy-profile-bridge';
|
import { resolveCliproxyBridgeMetadata } from '../api/services/cliproxy-profile-bridge';
|
||||||
|
import { getClaudeSubcommandName } from '../utils/claude-subcommand-detector';
|
||||||
import { resolveCodexRuntimeConfigOverrides } from './environment-builder';
|
import { resolveCodexRuntimeConfigOverrides } from './environment-builder';
|
||||||
import {
|
import {
|
||||||
detectProfile,
|
detectProfile,
|
||||||
@@ -83,6 +84,31 @@ function usesImplicitDefaultProfile(cleanArgs: string[]): boolean {
|
|||||||
return cleanArgs.length === 0 || cleanArgs[0]?.startsWith('-') === true;
|
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 <subcommand>` 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 {
|
function buildNativeCodexDefaultProfile(): ProfileDetectionResult {
|
||||||
return {
|
return {
|
||||||
type: 'default',
|
type: 'default',
|
||||||
@@ -123,8 +149,23 @@ export async function resolveProfileAndTarget(
|
|||||||
|
|
||||||
// Detect profile (strip --target flags before profile detection)
|
// Detect profile (strip --target flags before profile detection)
|
||||||
const cleanArgs = stripTargetFlag(args);
|
const cleanArgs = stripTargetFlag(args);
|
||||||
const { profile, remainingArgs } = detectProfile(cleanArgs);
|
const detected = detectProfile(cleanArgs);
|
||||||
let profileInfo: ProfileDetectionResult = detector.detectProfileType(profile);
|
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<typeof resolveTargetType>;
|
let resolvedTarget: ReturnType<typeof resolveTargetType>;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user