From 191396149ffa14d5bd6a59de91a594c6faabb6eb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 11:39:55 -0400 Subject: [PATCH 01/13] fix(auth): de-duplicate error output and validate legacy profiles in default exitWithError() already prints '[X] ', so the preceding console.log(fail()) calls double-printed every auth error. Remove the redundant logs and fold richer hints (e.g. --force) into the single exitWithError message. Also make 'auth default' accept a profile registered only in legacy profiles.json under unified mode. --- src/auth/commands/backup-command.ts | 3 +-- src/auth/commands/create-command.ts | 28 ++++++++++++-------------- src/auth/commands/default-command.ts | 1 - src/auth/commands/remove-command.ts | 4 +--- src/auth/commands/resources-command.ts | 3 +-- src/auth/commands/show-command.ts | 3 +-- src/auth/commands/types.ts | 3 +-- src/auth/profile-registry.ts | 12 ++++++++--- 8 files changed, 27 insertions(+), 30 deletions(-) diff --git a/src/auth/commands/backup-command.ts b/src/auth/commands/backup-command.ts index f6fe167d..8aa8b595 100644 --- a/src/auth/commands/backup-command.ts +++ b/src/auth/commands/backup-command.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { initUI, color, dim, fail, info, ok } from '../../utils/ui'; +import { initUI, color, dim, info, ok } from '../../utils/ui'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { @@ -44,7 +44,6 @@ export async function handleBackup(ctx: CommandContext, args: string[]): Promise }); if (!profileName) { - console.log(fail('Profile name is required')); console.log(''); console.log(`Usage: ${color('ccs auth backup [--json]', 'command')}`); exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index 05eacc4a..ee78ffba 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -53,7 +53,6 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise }); if (!profileName) { - console.log(fail('Profile name is required')); console.log(''); console.log( `Usage: ${color('ccs auth create [--force] [--bare] [--share-context] [--context-group ] [--deeper-continuity]', 'command')}` @@ -65,20 +64,21 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise } if (!isValidAccountProfileName(profileName)) { - const error = - 'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.'; - console.log(fail(error)); - console.log(''); - exitWithError(error, ExitCode.PROFILE_ERROR); + exitWithError( + 'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.', + ExitCode.PROFILE_ERROR + ); } // Check if profile already exists (check both legacy and unified) const existsLegacy = ctx.registry.hasProfile(profileName); const existsUnified = ctx.registry.hasAccountUnified(profileName); if (!force && (existsLegacy || existsUnified)) { - console.log(fail(`Profile already exists: ${profileName}`)); - console.log(` Use ${color('--force', 'command')} to overwrite`); - exitWithError(`Profile already exists: ${profileName}`, ExitCode.PROFILE_ERROR); + // Keep the --force hint in the exitWithError message so it appears in the single output line + exitWithError( + `Profile already exists: ${profileName}\n Use --force to overwrite`, + ExitCode.PROFILE_ERROR + ); } const normalizedName = sanitizeProfileNameForInstance(profileName); @@ -87,10 +87,10 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise ); if (collidingName) { - const error = `Profile "${profileName}" conflicts with existing profile "${collidingName}" on filesystem.`; - console.log(fail(error)); - console.log(''); - exitWithError(error, ExitCode.PROFILE_ERROR); + exitWithError( + `Profile "${profileName}" conflicts with existing profile "${collidingName}" on filesystem.`, + ExitCode.PROFILE_ERROR + ); } const resolvedContext = resolveCreateAccountContext({ @@ -100,8 +100,6 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise }); if (resolvedContext.error) { - console.log(fail(resolvedContext.error)); - console.log(''); exitWithError(resolvedContext.error, ExitCode.PROFILE_ERROR); } diff --git a/src/auth/commands/default-command.ts b/src/auth/commands/default-command.ts index 8836aee1..4e3b6bc1 100644 --- a/src/auth/commands/default-command.ts +++ b/src/auth/commands/default-command.ts @@ -23,7 +23,6 @@ export async function handleDefault(ctx: CommandContext, args: string[]): Promis }); if (!profileName) { - console.log(fail('Profile name is required')); console.log(''); console.log(`Usage: ${color('ccs auth default ', 'command')}`); exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); diff --git a/src/auth/commands/remove-command.ts b/src/auth/commands/remove-command.ts index fed45098..60f4bb3d 100644 --- a/src/auth/commands/remove-command.ts +++ b/src/auth/commands/remove-command.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { initUI, color, ok, fail, info } from '../../utils/ui'; +import { initUI, color, ok, info } from '../../utils/ui'; import { InteractivePrompt } from '../../utils/prompt'; import { exitWithError } from '../../errors'; @@ -26,7 +26,6 @@ export async function handleRemove(ctx: CommandContext, args: string[]): Promise }); if (!profileName) { - console.log(fail('Profile name is required')); console.log(''); console.log(`Usage: ${color('ccs auth remove [--yes]', 'command')}`); exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); @@ -37,7 +36,6 @@ export async function handleRemove(ctx: CommandContext, args: string[]): Promise const existsUnified = ctx.registry.hasAccountUnified(profileName); if (!existsLegacy && !existsUnified) { - console.log(fail(`Profile not found: ${profileName}`)); exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR); } diff --git a/src/auth/commands/resources-command.ts b/src/auth/commands/resources-command.ts index cf6666c3..af7e0bf0 100644 --- a/src/auth/commands/resources-command.ts +++ b/src/auth/commands/resources-command.ts @@ -1,4 +1,4 @@ -import { initUI, header, color, fail, ok, table } from '../../utils/ui'; +import { initUI, header, color, ok, table } from '../../utils/ui'; import { resolveAccountContextPolicy } from '../account-context'; import { isSharedResourceMode, @@ -30,7 +30,6 @@ export async function handleResources(ctx: CommandContext, args: string[]): Prom }); if (!profileName) { - console.log(fail('Profile name is required')); console.log(''); console.log( `Usage: ${color('ccs auth resources [--mode shared|profile-local] [--json]', 'command')}` diff --git a/src/auth/commands/show-command.ts b/src/auth/commands/show-command.ts index 37602688..2ae38136 100644 --- a/src/auth/commands/show-command.ts +++ b/src/auth/commands/show-command.ts @@ -6,7 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { initUI, header, color, fail, table } from '../../utils/ui'; +import { initUI, header, color, table } from '../../utils/ui'; import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context'; import { describeSettingsSync, summarizeAccountHistory } from '../account-profile-diagnostics'; import { resolveConfiguredPlainCcsResumeLane } from '../resume-lane-diagnostics'; @@ -33,7 +33,6 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise [--json]', 'command')}`); exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); diff --git a/src/auth/commands/types.ts b/src/auth/commands/types.ts index f091b410..b67d7298 100644 --- a/src/auth/commands/types.ts +++ b/src/auth/commands/types.ts @@ -8,7 +8,7 @@ import ProfileRegistry from '../profile-registry'; import { InstanceManager } from '../../management/instance-manager'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; -import { color, fail } from '../../utils/ui'; +import { color } from '../../utils/ui'; // Re-export for backward compatibility export { formatRelativeTime } from '../../utils/time'; @@ -100,7 +100,6 @@ export function rejectUnsupportedAuthOptions( } const unknownList = unsupportedOptions.map((flag) => `"${flag}"`).join(', '); - console.log(fail(`Unknown option(s): ${unknownList}`)); console.log(''); console.log(`Usage: ${color(options.usage, 'command')}`); console.log(`Help: ${color('ccs auth --help', 'command')}`); diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index b910b298..22c19203 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -376,13 +376,19 @@ export class ProfileRegistry { } /** - * Set default profile in unified config + * Set default profile in unified config. + * Accepts names from unified config (accounts, profiles, cliproxy variants) + * as well as legacy profiles.json so that mixed-mode installs work correctly. */ setDefaultUnified(name: string): void { + // Check legacy registry outside the mutate callback to avoid re-reading inside the transaction. + const legacyData = this._read(); + const existsLegacy = !!legacyData.profiles[name]; + mutateConfig((config) => { - const exists = + const existsUnified = config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name]; - if (!exists) { + if (!existsUnified && !existsLegacy) { throw new ProfileError(`Profile not found: ${name}`, name); } config.default = name; From 9258aed1ff44dbb8d6318c5f0f43fdf077aae08a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 11:39:55 -0400 Subject: [PATCH 02/13] fix(cli): clearer profile/proxy/api errors and rich not-found suggestions Move resolveProfileAndTarget() inside the try block in ccs.ts so a missing profile shows suggestions/available-profiles instead of a generic error. Use a typed ProfileError for proxy start on an unknown profile (drops the irrelevant install hint). api create: honor --yes for the OpenRouter model browser, show ccs-codex/ccsx aliases for --target codex, and allow ANTHROPIC_EXTRA_MODELS on import. --- .../services/profile-lifecycle-validation.ts | 2 + src/ccs.ts | 54 ++++++++++--------- src/commands/api-command/create-command.ts | 30 ++++++++--- src/commands/proxy-command.ts | 16 +++++- 4 files changed, 68 insertions(+), 34 deletions(-) diff --git a/src/api/services/profile-lifecycle-validation.ts b/src/api/services/profile-lifecycle-validation.ts index 7ff09654..d7902584 100644 --- a/src/api/services/profile-lifecycle-validation.ts +++ b/src/api/services/profile-lifecycle-validation.ts @@ -23,6 +23,8 @@ const ALLOWED_ANTHROPIC_ENV_KEYS = new Set([ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', + // Written by profile-writer when --extra-models is supplied + 'ANTHROPIC_EXTRA_MODELS', ...MODEL_ENV_KEYS, ]); diff --git a/src/ccs.ts b/src/ccs.ts index 28a89ec3..e8fe5996 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -46,31 +46,37 @@ async function main(): Promise { return; } - // Phase C: profile + target detection (extracted to dispatcher/profile-resolver.ts) - const resolvedProfile = await resolveProfileAndTarget({ args, browserLaunchOverride, cliLogger }); - const { profile, profileInfo, resolvedTarget, nativeClaudeRemainingArgs } = resolvedProfile; - - // Dynamic imports needed by Phase E flows — preserve original load ordering. - const InstanceManagerModule = await import('./management/instance-manager'); - const InstanceManager = InstanceManagerModule.default; - const ProfileRegistryModule = await import('./auth/profile-registry'); - const ProfileRegistry = ProfileRegistryModule.default; - const AccountContextModule = await import('./auth/account-context'); - const { resolveAccountContextPolicy, isAccountContextMetadata } = AccountContextModule; - const ProfileContinuityModule = await import('./auth/profile-continuity-inheritance'); - const { resolveProfileContinuityInheritance } = ProfileContinuityModule; - - // Build full dispatch context (Phase E) - const dispatchCtx = { - ...resolvedProfile, - InstanceManager, - ProfileRegistry, - resolveAccountContextPolicy, - isAccountContextMetadata, - resolveProfileContinuityInheritance, - }; - try { + // Phase C: profile + target detection — inside try so ProfileNotFoundError + // reaches the rich showProfileNotFound() handler below instead of falling + // through to the bare unhandledRejection handler. + const resolvedProfile = await resolveProfileAndTarget({ + args, + browserLaunchOverride, + cliLogger, + }); + const { profile, profileInfo, resolvedTarget, nativeClaudeRemainingArgs } = resolvedProfile; + + // Dynamic imports needed by Phase E flows — preserve original load ordering. + const InstanceManagerModule = await import('./management/instance-manager'); + const InstanceManager = InstanceManagerModule.default; + const ProfileRegistryModule = await import('./auth/profile-registry'); + const ProfileRegistry = ProfileRegistryModule.default; + const AccountContextModule = await import('./auth/account-context'); + const { resolveAccountContextPolicy, isAccountContextMetadata } = AccountContextModule; + const ProfileContinuityModule = await import('./auth/profile-continuity-inheritance'); + const { resolveProfileContinuityInheritance } = ProfileContinuityModule; + + // Build full dispatch context (Phase E) + const dispatchCtx = { + ...resolvedProfile, + InstanceManager, + ProfileRegistry, + resolveAccountContextPolicy, + isAccountContextMetadata, + resolveProfileContinuityInheritance, + }; + // Special case: headless delegation (-p/--prompt) // Keep existing behavior for Claude targets only; non-claude targets must continue // through normal adapter dispatch logic. diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index b0d799f8..77b585f3 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -196,14 +196,17 @@ async function resolveModelConfiguration( if (isOpenRouterUrl(baseUrl) && !providedModel) { console.log(''); console.log(info('OpenRouter detected!')); - const useInteractive = await InteractivePrompt.confirm('Browse models interactively?', { - default: true, - }); - if (useInteractive) { - const selection = await pickOpenRouterModel(); - if (selection) { - openRouterModel = selection.model; - openRouterTierMapping = selection.tierMapping; + // --yes/-y suppresses interactive model browser; use preset defaultModel instead + if (!yes) { + const useInteractive = await InteractivePrompt.confirm('Browse models interactively?', { + default: true, + }); + if (useInteractive) { + const selection = await pickOpenRouterModel(); + if (selection) { + openRouterModel = selection.model; + openRouterTierMapping = selection.tierMapping; + } } } console.log(''); @@ -571,6 +574,17 @@ export async function handleApiCreateCommand(args: string[]): Promise { console.log( ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` ); + } else if (target === 'codex') { + console.log( + ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses Codex CLI by default')}` + ); + console.log( + ` ${color(`ccs-codex ${name} "your prompt"`, 'command')} ${dim('# explicit Codex alias')}` + ); + console.log(` ${color(`ccsx ${name} "your prompt"`, 'command')} ${dim('# short alias')}`); + console.log( + ` ${color(`ccs ${name} --target claude "your prompt"`, 'command')} ${dim('# override to Claude')}` + ); } else { console.log( ` ${color(`ccs ${name} "your prompt"`, 'command')} ${dim('# uses claude by default')}` diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts index 53a11b11..ba066702 100644 --- a/src/commands/proxy-command.ts +++ b/src/commands/proxy-command.ts @@ -10,7 +10,8 @@ import { startOpenAICompatProxy, stopOpenAICompatProxy, } from '../proxy'; -import { loadSettings } from '../config/config-loader-facade'; +import { loadConfigSafe, loadSettings } from '../config/config-loader-facade'; +import { ProfileError } from '../errors'; function parseOptionValue(args: string[], key: string): string | undefined { const exactIndex = args.findIndex((arg) => arg === key); @@ -82,11 +83,22 @@ function showHelp(): number { } function resolveProfile(profileName: string) { + // Pre-check profile in config to avoid the generic error() + npm-install hint from getSettingsPath. + const config = loadConfigSafe(); + if (!config.profiles[profileName]) { + throw new ProfileError( + `Profile "${profileName}" is not configured for an OpenAI-compatible endpoint`, + profileName + ); + } const settingsPath = expandPath(getSettingsPath(profileName)); const settings = loadSettings(settingsPath); const profile = resolveOpenAICompatProfileConfig(profileName, settingsPath, settings.env || {}); if (!profile) { - throw new Error(`Profile "${profileName}" is not configured for an OpenAI-compatible endpoint`); + throw new ProfileError( + `Profile "${profileName}" is not configured for an OpenAI-compatible endpoint`, + profileName + ); } return profile; } From 29007437677183d856d0081a16d28461c7f4da4a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 11:40:10 -0400 Subject: [PATCH 03/13] fix(dispatch): route unknown bar/copilot/cursor subcommands and survive corrupt config Pre-dispatch only routed known subcommand tokens, so unknown bar/copilot/cursor subcommands never reached their handlers (no error, no non-zero exit). Route them through. Also wrap autoMigrate() so a corrupt config.yaml no longer crashes startup, and print only the first line of a best-effort recovery failure. --- src/commands/bar/index.ts | 1 + src/dispatcher/pre-dispatch.ts | 42 ++++++++++++++++++++++------------ 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/commands/bar/index.ts b/src/commands/bar/index.ts index 27ecb28b..f12d8cd4 100644 --- a/src/commands/bar/index.ts +++ b/src/commands/bar/index.ts @@ -66,6 +66,7 @@ export async function handleBarCommand(args: string[]): Promise { if (!handler) { console.error(`[X] Unknown bar subcommand: ${subcommand}`); console.error('[i] Usage: ccs bar [launch|serve|stop|status|install|uninstall|version|--help]'); + process.exitCode = 1; return; } diff --git a/src/dispatcher/pre-dispatch.ts b/src/dispatcher/pre-dispatch.ts index aef943c6..e5c0d4eb 100644 --- a/src/dispatcher/pre-dispatch.ts +++ b/src/dispatcher/pre-dispatch.ts @@ -68,10 +68,19 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise" and exit 1. + // Unknown tokens that are NOT subcommand-like are kept as profile passthrough. if (firstArg === 'copilot' && args.length > 1) { const copilotToken = args[1]; - const shouldRouteToCopilotCommand = isCopilotSubcommandToken(copilotToken); + const isKnownSubcommand = isCopilotSubcommandToken(copilotToken); + // Route any non-flag, non-profile-looking second token to the handler so + // the default branch fires with the correct error message. + const looksLikeCopilotSubcmd = + isKnownSubcommand || (typeof copilotToken === 'string' && !copilotToken.startsWith('-')); - if (shouldRouteToCopilotCommand) { + if (looksLikeCopilotSubcmd) { const { handleCopilotCommand } = await import('../commands/copilot-command'); const exitCode = await handleCopilotCommand(args.slice(1)); process.exit(exitCode); @@ -125,14 +142,11 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise 1) { const { handleCursorCommand } = await import('../commands/cursor-command'); - const cursorToken = args[1]; - - if (isCursorSubcommandToken(cursorToken)) { - const exitCode = await handleCursorCommand(args.slice(1)); - process.exit(exitCode); - } + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); } // Compatibility shim: old `ccs cursor ` still forwards to the legacy bridge From 8cfd13ba96956532babbc171c9d9f997401620b0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 11:40:10 -0400 Subject: [PATCH 04/13] fix(doctor): keep diagnostics running on corrupt config or unreadable auth loadUnifiedConfig() printed the YAML error on every call (3-4x per run); print it once per path per process. Run each doctor check group defensively so a thrown check (corrupt config, EACCES auth dir) records an error and the SUMMARY/ERRORS sections with recovery hints still render instead of crashing mid-run. Trim embedded parser snippets to one line so they no longer leak into the summary table or repeat across checks. --- src/commands/setup-command.ts | 11 ++++++- src/config/unified-config-loader.ts | 41 ++++++++++++++++--------- src/management/checks/cliproxy-check.ts | 21 ++++++++++++- src/management/checks/config-check.ts | 5 ++- src/management/checks/profile-check.ts | 40 +++++++++--------------- src/management/doctor.ts | 40 ++++++++++++++++++------ 6 files changed, 105 insertions(+), 53 deletions(-) diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index d5cccc2f..60e23b83 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -128,7 +128,16 @@ async function selectOption( export function isFirstTimeInstall(): boolean { // Check unified config first (config.yaml) if (hasUnifiedConfig()) { - const loaded = loadUnifiedConfig(); + let loaded: ReturnType; + try { + loaded = loadUnifiedConfig(); + } catch { + // Config exists but is corrupted/unparseable — treat as not first-time so we + // don't run the setup wizard; the YAML error was already printed by loadUnifiedConfig. + console.log(warn('Warning: ~/.ccs/config.yaml exists but appears corrupted')); + console.log(info(' Run `ccs setup --force` to reset, or `ccs doctor` to diagnose')); + return false; + } // Config exists but is corrupted/invalid - don't treat as first-time if (loaded === null) { diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 59fe94ea..dbd3cb3c 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -25,6 +25,12 @@ import { import type { UnifiedConfig } from './unified-config-types'; import { isUnifiedConfigEnabled } from './feature-flags'; +// loadUnifiedConfig() runs several times per invocation (startup migration check, +// first-time-install check, command handler, doctor checks). A corrupt config.yaml +// must report its parse error ONCE, not once per call. Track paths already warned +// so repeated loads in the same process stay quiet. +const warnedCorruptConfigPaths = new Set(); + // --------------------------------------------------------------------------- // Phase 1 re-exports: io-locks // --------------------------------------------------------------------------- @@ -167,22 +173,27 @@ export function loadUnifiedConfig(): UnifiedConfig | null { return parsed; } catch (err) { - // U3: Provide better context for YAML syntax errors - if (err instanceof yaml.YAMLException) { - const mark = err.mark; - console.error(`[X] YAML syntax error in ${yamlPath}:`); - console.error( - ` Line ${(mark?.line ?? 0) + 1}, Column ${(mark?.column ?? 0) + 1}: ${err.reason || 'Invalid syntax'}` - ); - if (mark?.snippet) { - console.error(` ${mark.snippet}`); + // U3: Provide better context for YAML syntax errors. Print at most once per + // path per process so repeated loads of a corrupt file do not spam the error. + const alreadyWarned = warnedCorruptConfigPaths.has(yamlPath); + if (!alreadyWarned) { + warnedCorruptConfigPaths.add(yamlPath); + if (err instanceof yaml.YAMLException) { + const mark = err.mark; + console.error(`[X] YAML syntax error in ${yamlPath}:`); + console.error( + ` Line ${(mark?.line ?? 0) + 1}, Column ${(mark?.column ?? 0) + 1}: ${err.reason || 'Invalid syntax'}` + ); + if (mark?.snippet) { + console.error(` ${mark.snippet}`); + } + console.error( + ` Tip: Check for missing colons, incorrect indentation, or unquoted special characters.` + ); + } else { + const error = err instanceof Error ? err.message : 'Unknown error'; + console.error(`[X] Failed to load config: ${error}`); } - console.error( - ` Tip: Check for missing colons, incorrect indentation, or unquoted special characters.` - ); - } else { - const error = err instanceof Error ? err.message : 'Unknown error'; - console.error(`[X] Failed to load config: ${error}`); } throw err; } diff --git a/src/management/checks/cliproxy-check.ts b/src/management/checks/cliproxy-check.ts index 4a4998b6..eb85d73e 100644 --- a/src/management/checks/cliproxy-check.ts +++ b/src/management/checks/cliproxy-check.ts @@ -108,7 +108,26 @@ export class CLIProxyAuthChecker implements IHealthChecker { name = 'CLIProxy Auth'; run(results: HealthCheck): void { - const authStatuses = getAllAuthStatus(); + // Reading auth status touches ~/.ccs; a permission error (EACCES) must not + // crash doctor before the ERRORS section renders. Surface it as a recoverable + // check instead of throwing. + let authStatuses; + try { + authStatuses = getAllAuthStatus(); + } catch (err) { + const spinner = ora('Checking CLIProxy auth').start(); + spinner.fail(); + const message = err instanceof Error ? err.message : String(err); + console.log(` ${warn('CLIProxy Auth'.padEnd(22))} Unable to read auth status`); + results.addCheck( + 'CLIProxy Auth', + 'error', + `Unable to read CLIProxy auth status: ${message}`, + 'sudo chown -R $USER ~/.ccs ~/.claude && chmod -R u+rwX ~/.ccs ~/.claude', + { status: 'ERROR', info: 'Auth status unavailable (permission or read error)' } + ); + return; + } for (const status of authStatuses) { const spinner = ora(`Checking ${status.provider} auth`).start(); const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1); diff --git a/src/management/checks/config-check.ts b/src/management/checks/config-check.ts index f3ac17e0..beee75e7 100644 --- a/src/management/checks/config-check.ts +++ b/src/management/checks/config-check.ts @@ -72,10 +72,13 @@ export class ConfigFilesChecker implements IHealthChecker { } catch (e) { spinner.fail(); console.log(` ${fail('config.yaml'.padEnd(22))} Invalid YAML`); + // First line only: the parser message embeds a multi-line snippet the + // loader already printed, so keep the ERRORS entry to a single line. + const reason = (e as Error).message.split('\n')[0].trim(); results.addCheck( 'config.yaml', 'error', - `Invalid YAML: ${(e as Error).message}`, + `Invalid YAML: ${reason}`, `Backup and recreate: mv ${configYamlPath} ${configYamlPath}.backup && npm install -g @kaitranntt/ccs --force`, { status: 'ERROR', info: 'Invalid YAML' } ); diff --git a/src/management/checks/profile-check.ts b/src/management/checks/profile-check.ts index dd55ecee..e1717f39 100644 --- a/src/management/checks/profile-check.ts +++ b/src/management/checks/profile-check.ts @@ -40,19 +40,14 @@ export class ProfilesChecker implements IHealthChecker { return; } catch (e) { spinner.fail(); - console.log( - ` ${fail('Profiles'.padEnd(22))} Invalid config.yaml: ${(e as Error).message}` - ); - results.addCheck( - 'Profiles', - 'error', - `Invalid config.yaml: ${(e as Error).message}`, - undefined, - { - status: 'ERROR', - info: (e as Error).message, - } - ); + // First line only: a YAML parse error embeds a multi-line snippet (already + // printed by the loader) that otherwise leaks into the summary table cell. + const reason = (e as Error).message.split('\n')[0].trim(); + console.log(` ${fail('Profiles'.padEnd(22))} Invalid config.yaml: ${reason}`); + results.addCheck('Profiles', 'error', `Invalid config.yaml: ${reason}`, undefined, { + status: 'ERROR', + info: reason, + }); return; } } @@ -65,19 +60,12 @@ export class ProfilesChecker implements IHealthChecker { return; } catch (e) { spinner.fail(); - console.log( - ` ${fail('Profiles'.padEnd(22))} Invalid config.json: ${(e as Error).message}` - ); - results.addCheck( - 'Profiles', - 'error', - `Invalid config.json: ${(e as Error).message}`, - undefined, - { - status: 'ERROR', - info: (e as Error).message, - } - ); + const reason = (e as Error).message.split('\n')[0].trim(); + console.log(` ${fail('Profiles'.padEnd(22))} Invalid config.json: ${reason}`); + results.addCheck('Profiles', 'error', `Invalid config.json: ${reason}`, undefined, { + status: 'ERROR', + info: reason, + }); return; } } diff --git a/src/management/doctor.ts b/src/management/doctor.ts index 13d0fa3a..458cdb8d 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -46,49 +46,71 @@ class Doctor { // Group 1: System console.log(header('SYSTEM')); - await runSystemChecks(this.results); + await this.safe('System Checks', () => runSystemChecks(this.results)); console.log(''); // Group 2: Environment (OAuth readiness diagnostics) console.log(header('ENVIRONMENT')); - runEnvironmentCheck(this.results); + await this.safe('Environment Check', () => runEnvironmentCheck(this.results)); console.log(''); // Group 3: Configuration console.log(header('CONFIGURATION')); - runConfigChecks(this.results); - this.runDockerKeyRotationCheck(); + await this.safe('Configuration Checks', () => runConfigChecks(this.results)); + await this.safe('Docker Key Rotation', () => this.runDockerKeyRotationCheck()); console.log(''); // Group 4: Profiles & Delegation console.log(header('PROFILES & DELEGATION')); - runProfileChecks(this.results); + await this.safe('Profile Checks', () => runProfileChecks(this.results)); console.log(''); // Group 5: System Health console.log(header('SYSTEM HEALTH')); - runSymlinkChecks(this.results); + await this.safe('Symlink Checks', () => runSymlinkChecks(this.results)); console.log(''); // Group 6: CLIProxy Plus (OAuth profiles) console.log(header('CLIPROXY PLUS (OAUTH PROFILES)')); - await runCLIProxyChecks(this.results); + await this.safe('CLIProxy Checks', () => runCLIProxyChecks(this.results)); console.log(''); // Group 7: OAuth Readiness (port availability) console.log(header('OAUTH READINESS')); - await runOAuthChecks(this.results); + await this.safe('OAuth Checks', () => runOAuthChecks(this.results)); console.log(''); // Group 8: Image Analysis Config console.log(header('IMAGE ANALYSIS')); - await runImageAnalysisCheck(this.results); + await this.safe('Image Analysis Check', () => runImageAnalysisCheck(this.results)); console.log(''); this.showReport(); return this.results; } + /** + * Run one check group defensively. A thrown check (e.g. a corrupt config.yaml + * that makes a loader throw) is recorded as an error instead of aborting the + * whole run, so the SUMMARY and ERRORS sections (with recovery hints) always + * render. + */ + private async safe(label: string, fn: () => void | Promise): Promise { + try { + await fn(); + } catch (err) { + // Use only the first line of the message: parser errors (e.g. js-yaml) + // embed a multi-line snippet that the loader already printed once, so the + // ERRORS section stays one line per failed check. + const raw = err instanceof Error ? err.message : String(err); + const message = raw.split('\n')[0].trim(); + this.results.addCheck(label, 'error', message, undefined, { + status: 'ERROR', + info: 'check failed (see ERRORS)', + }); + } + } + /** * Show health check report */ From 9d5b33382b36e5c8ce4cc5f77f7c9e4264eeca86 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 11:40:10 -0400 Subject: [PATCH 05/13] fix(cliproxy): gate binary download log, fix pool message prefix and routing affinity help Only log 'Binary not found, downloading...' when a download will actually happen (after the allowInstall guard). Stop double-prefixing pool enable/disable messages that already carry [OK]/[!]. Route 'cliproxy routing affinity --help' to affinity help instead of the generic cliproxy help. --- src/cliproxy/binary/lifecycle.ts | 7 ++++--- src/commands/cliproxy/index.ts | 11 ++++++++--- src/commands/cliproxy/pool-subcommand.ts | 6 ++++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts index 474a3857..f16b103d 100644 --- a/src/cliproxy/binary/lifecycle.ts +++ b/src/cliproxy/binary/lifecycle.ts @@ -132,9 +132,7 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise return binaryPath; } - // Binary missing - download - log('Binary not found, downloading...', verbose); - + // Binary missing if (!config.allowInstall) { throw new Error( `${getBackendLabel(backend)} binary is not installed locally. ` + @@ -142,6 +140,9 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise ); } + // Only log download intent when a download will actually happen + log('Binary not found, downloading...', verbose); + if (!config.forceVersion) { try { const latestVersion = await fetchLatestVersion(verbose, backend); diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index b580fdda..b0595c62 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -146,9 +146,14 @@ export async function handleCliproxyCommand(args: string[]): Promise { const verbose = hasAnyFlag(remainingArgs, ['--verbose', '-v']); const command = remainingArgs[0]; - if (hasAnyFlag(remainingArgs, ['--help', '-h'])) { - await showHelp(); - return; + // Show global cliproxy help only when --help/-h is the top-level intent (no + // subcommand, or the flag is the first arg). Subcommands that accept --help + // (e.g. routing affinity --help) handle it themselves after dispatch. + if (!command || command === '--help' || command === '-h') { + if (hasAnyFlag(remainingArgs, ['--help', '-h'])) { + await showHelp(); + return; + } } // Catalog commands diff --git a/src/commands/cliproxy/pool-subcommand.ts b/src/commands/cliproxy/pool-subcommand.ts index a2758bbe..c19cb269 100644 --- a/src/commands/cliproxy/pool-subcommand.ts +++ b/src/commands/cliproxy/pool-subcommand.ts @@ -79,7 +79,8 @@ export async function handlePoolSubcommand(args: string[]): Promise { console.log(warn(result.message)); process.exitCode = 1; } else if (result.changed) { - console.log(ok(result.message)); + // message already carries the correct [OK]/[!] prefix from routing-strategy + console.log(result.message); } else { console.log(info(result.message)); } @@ -93,7 +94,8 @@ export async function handlePoolSubcommand(args: string[]): Promise { console.log(warn(result.message)); process.exitCode = 1; } else if (result.changed) { - console.log(ok(result.message)); + // message already carries the correct [OK]/[!] prefix from routing-strategy + console.log(result.message); } else { console.log(info(result.message)); } From e4c91d285330ba73b45f78c159355d8bae996a0a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 11:40:10 -0400 Subject: [PATCH 06/13] fix(persist): accurate symlink guard message on backup read The guard fires on the backup READ step; use a typed ConfigError whose message matches the actual step (refusing to read), not a misleading 'refusing to write'. --- src/commands/persist-command/backup-rotation.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/commands/persist-command/backup-rotation.ts b/src/commands/persist-command/backup-rotation.ts index deba4449..2561ff0a 100644 --- a/src/commands/persist-command/backup-rotation.ts +++ b/src/commands/persist-command/backup-rotation.ts @@ -11,6 +11,7 @@ import * as path from 'path'; import { initUI, header, color, dim, ok, fail, warn, info } from '../../utils/ui'; import { InteractivePrompt } from '../../utils/prompt'; import { getClaudeSettingsPath } from '../../utils/claude-config-path'; +import { ConfigError } from '../../errors'; import { formatDisplayPath, getClaudeSettingsDisplayPath, @@ -86,6 +87,14 @@ export async function createBackup(): Promise { throw new Error('No settings.json to backup'); } + // Guard: refuse to read a symlinked settings.json (TOCTOU mitigation on backup read). + if (await isSymlinkAsync(settingsPath)) { + throw new ConfigError( + 'settings.json is a symlink - refusing to read for backup for security', + settingsPath + ); + } + const settingsContent = await readFileUtf8NoFollow(settingsPath); const now = new Date(); From 016c47ec9acb1db4b8b9a4aec4a123be869ffee8 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 11:40:10 -0400 Subject: [PATCH 07/13] chore(lint): refresh throw-error baseline for shifted and converted throws --- eslint-rules/throw-error-baseline.json | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/eslint-rules/throw-error-baseline.json b/eslint-rules/throw-error-baseline.json index 79ab7670..a30cb176 100644 --- a/eslint-rules/throw-error-baseline.json +++ b/eslint-rules/throw-error-baseline.json @@ -39,7 +39,7 @@ "src/cliproxy/binary/installer.ts:55", "src/cliproxy/binary/installer.ts:74", "src/cliproxy/binary/installer.ts:88", - "src/cliproxy/binary/lifecycle.ts:139", + "src/cliproxy/binary/lifecycle.ts:137", "src/cliproxy/binary/platform-detector.ts:153", "src/cliproxy/binary/platform-detector.ts:160", "src/cliproxy/binary/verifier.ts:55", @@ -62,7 +62,6 @@ "src/cliproxy/executor/proxy-resolver.ts:163", "src/cliproxy/executor/proxy-resolver.ts:177", "src/cliproxy/executor/session-bridge.ts:147", - "src/cliproxy/provider-capabilities.ts:257", "src/cliproxy/proxy/https-tunnel-proxy.ts:61", "src/cliproxy/routing/routing-strategy.ts:480", "src/cliproxy/routing/routing-strategy.ts:488", @@ -121,10 +120,10 @@ "src/commands/config-channels-command.ts:436", "src/commands/config-channels-command.ts:447", "src/commands/persist-command/arg-parsing.ts:31", - "src/commands/persist-command/backup-rotation.ts:226", - "src/commands/persist-command/backup-rotation.ts:244", - "src/commands/persist-command/backup-rotation.ts:249", - "src/commands/persist-command/backup-rotation.ts:86", + "src/commands/persist-command/backup-rotation.ts:235", + "src/commands/persist-command/backup-rotation.ts:253", + "src/commands/persist-command/backup-rotation.ts:258", + "src/commands/persist-command/backup-rotation.ts:87", "src/commands/persist-command/handler.ts:166", "src/commands/persist-command/handler.ts:38", "src/commands/persist-command/secure-file.ts:104", @@ -134,14 +133,13 @@ "src/commands/persist-command/secure-file.ts:174", "src/commands/persist-command/secure-file.ts:182", "src/commands/persist-command/secure-file.ts:98", - "src/commands/proxy-command.ts:89", - "src/commands/setup-command.ts:218", + "src/commands/setup-command.ts:227", "src/config/loader/io-locks.ts:205", "src/config/loader/io-locks.ts:241", "src/config/loader/io-locks.ts:295", "src/config/loader/io-locks.ts:297", "src/config/reserved-names.ts:92", - "src/config/unified-config-loader.ts:149", + "src/config/unified-config-loader.ts:155", "src/copilot/copilot-package-manager.ts:444", "src/copilot/copilot-package-manager.ts:467", "src/cursor/cursor-anthropic-translator.ts:113", From a5feefe45a62044bbfede28edaaa175f20795399 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 12:05:34 -0400 Subject: [PATCH 08/13] fix(cliproxy): honor status --verbose and document --provider/diag in help handleProxyStatus now respects --verbose (was parsed but ignored). Document the existing --provider option on default/pause/resume and the 'diag' alias for doctor in the cliproxy help text. --- src/commands/cliproxy/help-subcommand.ts | 10 +++++----- src/commands/cliproxy/index.ts | 2 +- .../cliproxy/proxy-lifecycle-subcommand.ts | 15 ++++++++++++++- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 27e61e14..4049a616 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -53,9 +53,9 @@ export async function showHelp(): Promise { [ 'Quota Management:', [ - ['default ', 'Set default account for rotation'], - ['pause ', 'Pause account (skip in rotation)'], - ['resume ', 'Resume paused account'], + ['default [--provider ]', 'Set default account for rotation'], + ['pause [--provider ]', 'Pause account (skip in rotation)'], + ['resume [--provider ]', 'Resume paused account'], [ 'quota', 'Show quota status + pool context (drain order, per-account available/cooling/paused)', @@ -89,9 +89,9 @@ export async function showHelp(): Promise { [ ['start', 'Start CLIProxy instance in background'], ['restart', 'Restart CLIProxy instance'], - ['status', 'Show running CLIProxy status'], + ['status [--verbose]', 'Show running CLIProxy status (--verbose adds uptime)'], ['stop', 'Stop running CLIProxy instance'], - ['doctor', 'Quota diagnostics and shared project detection'], + ['doctor | diag', 'Quota diagnostics and shared project detection'], ], ], [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index b0595c62..e518f3ee 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -250,7 +250,7 @@ export async function handleCliproxyCommand(args: string[]): Promise { start: async () => handleStart(verbose), stop: async () => handleStop(), restart: async () => handleRestart(verbose), - status: async () => handleProxyStatus(), + status: async () => handleProxyStatus(verbose), doctor: async () => handleDoctor(verbose), diag: async () => handleDoctor(verbose), default: async () => handleSetDefault(remainingArgs.slice(1)), diff --git a/src/commands/cliproxy/proxy-lifecycle-subcommand.ts b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts index 001e995f..7c4e4462 100644 --- a/src/commands/cliproxy/proxy-lifecycle-subcommand.ts +++ b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts @@ -66,7 +66,7 @@ export async function handleRestart(verbose = false): Promise { console.log(''); } -export async function handleProxyStatus(): Promise { +export async function handleProxyStatus(verbose = false): Promise { await initUI(); console.log(header('CLIProxy Status')); console.log(''); @@ -80,7 +80,17 @@ export async function handleProxyStatus(): Promise { console.log(` Sessions: ${status.sessionCount || 0} active`); if (status.startedAt) { console.log(` Started: ${new Date(status.startedAt).toLocaleString()}`); + if (verbose) { + const uptimeMs = Date.now() - new Date(status.startedAt).getTime(); + const uptimeSec = Math.floor(uptimeMs / 1000); + const h = Math.floor(uptimeSec / 3600); + const m = Math.floor((uptimeSec % 3600) / 60); + const s = uptimeSec % 60; + const uptimeStr = h > 0 ? `${h}h ${m}m ${s}s` : m > 0 ? `${m}m ${s}s` : `${s}s`; + console.log(` Uptime: ${uptimeStr}`); + } } + console.log(''); console.log(dim('To stop: ccs cliproxy stop')); } else { @@ -94,6 +104,9 @@ export async function handleProxyStatus(): Promise { if (!detected.sessionCount) { console.log(dim(' Note: Detected running proxy without local session lock')); } + if (verbose) { + console.log(dim(' Note: Process detected via port scan; no local lock file present')); + } console.log(''); console.log(dim('To stop: ccs cliproxy stop')); } else { From 5e4a90b3e5694af0678d9f34b5e9c25c601a9a89 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 12:05:34 -0400 Subject: [PATCH 09/13] fix(cli): warn on tokens --variant without --api-key; list bar serve in help 'ccs tokens --variant ' without --api-key was a silent no-op; now it explains that --variant only applies with --api-key. Add the existing 'serve' subcommand to the bar help Commands list. --- src/commands/bar/help-subcommand.ts | 1 + src/commands/tokens-command.ts | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/commands/bar/help-subcommand.ts b/src/commands/bar/help-subcommand.ts index 0511a1ba..95b63095 100644 --- a/src/commands/bar/help-subcommand.ts +++ b/src/commands/bar/help-subcommand.ts @@ -14,6 +14,7 @@ export async function showHelp(): Promise { 'Commands:', [ ['launch', 'Spawn the server detached, write ~/.ccs/bar.json, open the app (default)'], + ['serve', 'Run the CCS Bar server in the foreground (used internally by launch)'], ['stop', 'Stop the detached CCS Bar server'], ['status', 'Show whether the CCS Bar server is running'], ['install', 'Download CCS Bar from the ccs-bar-latest GitHub release into ~/Applications'], diff --git a/src/commands/tokens-command.ts b/src/commands/tokens-command.ts index 7d3f06cf..0dfe92c9 100644 --- a/src/commands/tokens-command.ts +++ b/src/commands/tokens-command.ts @@ -141,6 +141,13 @@ export async function handleTokensCommand(args: string[]): Promise { return 1; } + // --variant without --api-key is a misuse — it has no effect without a key to assign. + if (variantValue !== undefined && !hasApiKeyFlag) { + console.error(fail('--variant requires --api-key')); + console.error(info('Usage: ccs tokens --variant --api-key ')); + return 1; + } + let updated = false; if (regenerateSecretFlag) { From 40d7617cc1cc316671f839c1af26be47fa189cc3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 12:05:34 -0400 Subject: [PATCH 10/13] fix(auth): re-export interface-only barrel symbols as types for bun dev runs The auth commands barrel re-exported 4 interface-only symbols (AuthCommandArgs, ProfileOutput, ListOutput, CommandContext) via a value 'export {}', which bun's per-file transpile cannot resolve at runtime (the tsc dist elides them, so users were unaffected). Split them into 'export type {}' so 'bun run dev' / source runs work. --- src/auth/commands/index.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/auth/commands/index.ts b/src/auth/commands/index.ts index e21869d5..571ee721 100644 --- a/src/auth/commands/index.ts +++ b/src/auth/commands/index.ts @@ -5,14 +5,8 @@ */ // Types and utilities -export { - AuthCommandArgs, - ProfileOutput, - ListOutput, - CommandContext, - parseArgs, - formatRelativeTime, -} from './types'; +export type { AuthCommandArgs, ProfileOutput, ListOutput, CommandContext } from './types'; +export { parseArgs, formatRelativeTime } from './types'; // Command handlers export { handleCreate } from './create-command'; From 310f4f6e522086090d9634812a94e9abde9d17a3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 12:18:50 -0400 Subject: [PATCH 11/13] fix(cliproxy): show global help regardless of flag order before --help Top-level cliproxy help was order-sensitive: 'cliproxy --verbose --help' fell through to dispatch instead of showing help. Fire global help whenever there is no real subcommand (no args or a leading flag) and --help/-h appears anywhere; real subcommands still handle their own --help. --- src/commands/cliproxy/index.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index e518f3ee..6382edda 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -146,14 +146,14 @@ export async function handleCliproxyCommand(args: string[]): Promise { const verbose = hasAnyFlag(remainingArgs, ['--verbose', '-v']); const command = remainingArgs[0]; - // Show global cliproxy help only when --help/-h is the top-level intent (no - // subcommand, or the flag is the first arg). Subcommands that accept --help - // (e.g. routing affinity --help) handle it themselves after dispatch. - if (!command || command === '--help' || command === '-h') { - if (hasAnyFlag(remainingArgs, ['--help', '-h'])) { - await showHelp(); - return; - } + // Show global cliproxy help when there is no real subcommand (no args, or the + // first token is a flag) and --help/-h appears anywhere — order-insensitive, so + // `cliproxy --verbose --help` still works. Real subcommands (e.g. + // `routing affinity --help`) handle --help themselves after dispatch. + const hasRealSubcommand = typeof command === 'string' && !command.startsWith('-'); + if (!hasRealSubcommand && hasAnyFlag(remainingArgs, ['--help', '-h'])) { + await showHelp(); + return; } // Catalog commands From d8cd2f94b58ad0d6799e81cac42384b851618c3a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 13:10:59 -0400 Subject: [PATCH 12/13] fix(cliproxy): keep --help working for all subcommands The earlier help-gating overcorrected: scoping global help to 'no real subcommand' meant 'cliproxy status --help' (and list/quota) ran the command and ignored --help. Restore global help on --help/-h anywhere (order-insensitive), carving out only 'routing affinity --help', which renders its own help. --- src/commands/cliproxy/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 6382edda..8acaea13 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -146,12 +146,12 @@ export async function handleCliproxyCommand(args: string[]): Promise { const verbose = hasAnyFlag(remainingArgs, ['--verbose', '-v']); const command = remainingArgs[0]; - // Show global cliproxy help when there is no real subcommand (no args, or the - // first token is a flag) and --help/-h appears anywhere — order-insensitive, so - // `cliproxy --verbose --help` still works. Real subcommands (e.g. - // `routing affinity --help`) handle --help themselves after dispatch. - const hasRealSubcommand = typeof command === 'string' && !command.startsWith('-'); - if (!hasRealSubcommand && hasAnyFlag(remainingArgs, ['--help', '-h'])) { + // Show global cliproxy help whenever --help/-h appears (order-insensitive, so + // `cliproxy status --help` and `cliproxy --verbose --help` both work). The one + // exception is `routing affinity --help`, which renders affinity-specific help + // after dispatch (handled below). + const isRoutingAffinity = command === 'routing' && remainingArgs[1] === 'affinity'; + if (!isRoutingAffinity && hasAnyFlag(remainingArgs, ['--help', '-h'])) { await showHelp(); return; } From 819d43914a7a85e14afde0f117b1f56d61a11cab Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 22 Jun 2026 13:10:59 -0400 Subject: [PATCH 13/13] fix(dispatch): restore copilot and legacy-cursor arg passthrough Routing every non-flag token after 'copilot'/'legacy-cursor' to the subcommand handler turned the legacy profile/prompt passthrough into an 'Unknown subcommand' error. Route only known subcommands (matching prior behavior); other args pass through to the copilot/cursor bridge flow. Bar keeps its unknown-subcommand exit code (it has no passthrough). --- src/dispatcher/pre-dispatch.ts | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/dispatcher/pre-dispatch.ts b/src/dispatcher/pre-dispatch.ts index e5c0d4eb..32b4c897 100644 --- a/src/dispatcher/pre-dispatch.ts +++ b/src/dispatcher/pre-dispatch.ts @@ -122,19 +122,14 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise" and exit 1. - // Unknown tokens that are NOT subcommand-like are kept as profile passthrough. + // Special case: copilot command (GitHub Copilot integration). + // Route ONLY known subcommands to the handler; any other arg is kept as + // passthrough to the copilot bridge profile flow (legacy behavior). Do not + // treat an arbitrary token as an unknown subcommand error, or this breaks the + // passthrough path. if (firstArg === 'copilot' && args.length > 1) { const copilotToken = args[1]; - const isKnownSubcommand = isCopilotSubcommandToken(copilotToken); - // Route any non-flag, non-profile-looking second token to the handler so - // the default branch fires with the correct error message. - const looksLikeCopilotSubcmd = - isKnownSubcommand || (typeof copilotToken === 'string' && !copilotToken.startsWith('-')); - - if (looksLikeCopilotSubcmd) { + if (isCopilotSubcommandToken(copilotToken)) { const { handleCopilotCommand } = await import('../commands/copilot-command'); const exitCode = await handleCopilotCommand(args.slice(1)); process.exit(exitCode); @@ -142,11 +137,15 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise 1) { - const { handleCursorCommand } = await import('../commands/cursor-command'); - const exitCode = await handleCursorCommand(args.slice(1)); - process.exit(exitCode); + const cursorToken = args[1]; + if (isCursorSubcommandToken(cursorToken)) { + const { handleCursorCommand } = await import('../commands/cursor-command'); + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); + } } // Compatibility shim: old `ccs cursor ` still forwards to the legacy bridge