mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 04:18:19 +00:00
Merge pull request #1583 from kaitranntt/kai/fix/cli-qa-ux-errors
fix(cli): UX and logistical fixes from a full feature-QA pass
This commit is contained in:
30 files changed
+286
-159
No files matched your search
@@ -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",
|
||||
|
||||
@@ -23,6 +23,8 @@ const ALLOWED_ANTHROPIC_ENV_KEYS = new Set<string>([
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_API_KEY',
|
||||
// Written by profile-writer when --extra-models is supplied
|
||||
'ANTHROPIC_EXTRA_MODELS',
|
||||
...MODEL_ENV_KEYS,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 <profile|default> [--json]', 'command')}`);
|
||||
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
|
||||
|
||||
@@ -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 <profile> [--force] [--bare] [--share-context] [--context-group <name>] [--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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <profile>', 'command')}`);
|
||||
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 <profile> [--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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <profile> [--mode shared|profile-local] [--json]', 'command')}`
|
||||
|
||||
@@ -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<v
|
||||
});
|
||||
|
||||
if (!profileName) {
|
||||
console.log(fail('Profile name is required'));
|
||||
console.log('');
|
||||
console.log(`Usage: ${color('ccs auth show <profile> [--json]', 'command')}`);
|
||||
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
|
||||
|
||||
@@ -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')}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+30
-24
@@ -46,31 +46,37 @@ async function main(): Promise<void> {
|
||||
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.
|
||||
|
||||
@@ -132,9 +132,7 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise<string>
|
||||
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<string>
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -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<void> {
|
||||
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')}`
|
||||
|
||||
@@ -14,6 +14,7 @@ export async function showHelp(): Promise<void> {
|
||||
'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'],
|
||||
|
||||
@@ -66,6 +66,7 @@ export async function handleBarCommand(args: string[]): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,9 +53,9 @@ export async function showHelp(): Promise<void> {
|
||||
[
|
||||
'Quota Management:',
|
||||
[
|
||||
['default <account>', 'Set default account for rotation'],
|
||||
['pause <account>', 'Pause account (skip in rotation)'],
|
||||
['resume <account>', 'Resume paused account'],
|
||||
['default <account> [--provider <name>]', 'Set default account for rotation'],
|
||||
['pause <account> [--provider <name>]', 'Pause account (skip in rotation)'],
|
||||
['resume <account> [--provider <name>]', '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<void> {
|
||||
[
|
||||
['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'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -146,7 +146,12 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
||||
const verbose = hasAnyFlag(remainingArgs, ['--verbose', '-v']);
|
||||
const command = remainingArgs[0];
|
||||
|
||||
if (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;
|
||||
}
|
||||
@@ -245,7 +250,7 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
||||
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)),
|
||||
|
||||
@@ -79,7 +79,8 @@ export async function handlePoolSubcommand(args: string[]): Promise<void> {
|
||||
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<void> {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function handleRestart(verbose = false): Promise<void> {
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export async function handleProxyStatus(): Promise<void> {
|
||||
export async function handleProxyStatus(verbose = false): Promise<void> {
|
||||
await initUI();
|
||||
console.log(header('CLIProxy Status'));
|
||||
console.log('');
|
||||
@@ -80,7 +80,17 @@ export async function handleProxyStatus(): Promise<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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<string> {
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<typeof loadUnifiedConfig>;
|
||||
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) {
|
||||
|
||||
@@ -141,6 +141,13 @@ export async function handleTokensCommand(args: string[]): Promise<number> {
|
||||
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 <name> --api-key <key>'));
|
||||
return 1;
|
||||
}
|
||||
|
||||
let updated = false;
|
||||
|
||||
if (regenerateSecretFlag) {
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -68,10 +68,19 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise<b
|
||||
}
|
||||
|
||||
// Auto-migrate to unified config format (silent if already migrated)
|
||||
// Skip if user is explicitly running migrate command
|
||||
// Skip if user is explicitly running migrate command.
|
||||
// Wrapped in try-catch so a corrupt config.yaml does not crash pre-dispatch
|
||||
// before doctor/setup can run and report the problem to the user.
|
||||
if (firstArg !== 'migrate') {
|
||||
const { autoMigrate } = await import('../config/migration-manager');
|
||||
await autoMigrate();
|
||||
try {
|
||||
const { autoMigrate } = await import('../config/migration-manager');
|
||||
await autoMigrate();
|
||||
} catch (err) {
|
||||
cliLogger.warn('migration.failed', 'Auto-migration failed (config may be corrupt)', {
|
||||
message: (err as Error).message,
|
||||
});
|
||||
// Do not print the error again — loadUnifiedConfig already printed the YAML details.
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-recovery for missing configuration (BEFORE any early-exit commands)
|
||||
@@ -90,8 +99,10 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise<b
|
||||
cliLogger.warn('recovery.failed', 'Auto-recovery failed during CLI startup', {
|
||||
message: (err as Error).message,
|
||||
});
|
||||
// Recovery is best-effort - don't block basic CLI functionality
|
||||
console.warn('[!] Recovery failed:', (err as Error).message);
|
||||
// Recovery is best-effort - don't block basic CLI functionality. Use only the
|
||||
// first line: a YAML parse error embeds a multi-line snippet the loader already
|
||||
// printed, so re-emitting it here just duplicates the noise.
|
||||
console.warn('[!] Recovery failed:', (err as Error).message.split('\n')[0].trim());
|
||||
}
|
||||
|
||||
// Root command router (handles --help, --version, config, doctor, etc.)
|
||||
@@ -111,13 +122,14 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise<b
|
||||
return true;
|
||||
}
|
||||
|
||||
// Special case: copilot command (GitHub Copilot integration)
|
||||
// Route known subcommands to command handler, keep all other args 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 shouldRouteToCopilotCommand = isCopilotSubcommandToken(copilotToken);
|
||||
|
||||
if (shouldRouteToCopilotCommand) {
|
||||
if (isCopilotSubcommandToken(copilotToken)) {
|
||||
const { handleCopilotCommand } = await import('../commands/copilot-command');
|
||||
const exitCode = await handleCopilotCommand(args.slice(1));
|
||||
process.exit(exitCode);
|
||||
@@ -125,11 +137,12 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise<b
|
||||
}
|
||||
|
||||
// Special case: explicit legacy Cursor bridge namespace.
|
||||
// Route ONLY known subcommands; keep other args as passthrough to the cursor
|
||||
// bridge flow (legacy behavior).
|
||||
if (firstArg === LEGACY_CURSOR_PROFILE_NAME && args.length > 1) {
|
||||
const { handleCursorCommand } = await import('../commands/cursor-command');
|
||||
const cursorToken = args[1];
|
||||
|
||||
if (isCursorSubcommandToken(cursorToken)) {
|
||||
const { handleCursorCommand } = await import('../commands/cursor-command');
|
||||
const exitCode = await handleCursorCommand(args.slice(1));
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' }
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>): Promise<void> {
|
||||
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
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user