refactor: P5 god-file splits (test-backed, public API preserved)

Epic P5. Splits 4 test-backed god-files into focused submodules using the
Monster File Splitting methodology; each original file is now a thin barrel
re-exporting the full public surface, so consumers are unchanged.

- src/management/shared-manager.ts (1631 -> barrel 21 + 10 submodules,
  max 337 LOC): fs-helpers, symlink-helpers, plugin-metadata-normalizer,
  plugin-layout-internals, shared-dir-linker, project-context-sync,
  project-memory-sync, migrations, orchestrator, types.
- src/web-server/routes/cliproxy-stats-routes.ts (1216 -> barrel 23 + 8
  submodules, max 290 LOC): shared, quota-helpers, version-helpers,
  restart-route, quota-routes, error-log-routes, config-routes, router.
- src/commands/persist-command.ts (1071 -> barrel + 8 submodules, max 258):
  types, arg-parsing, secure-file, backup-rotation, secret-detection,
  receipt, help, handler.
- src/commands/cliproxy/quota-subcommand.ts (1130 -> barrel + 14 submodules,
  max 303): types, format-helpers, codex/claude-window-helpers, provider-runtime,
  handlers, per-provider sections.

Public API preserved verbatim (default + named exports). All P3 structured
logging and P4 typed errors preserved through the splits. Each resulting file
< 400 LOC. Existing tests (shared-manager x4, cliproxy-stats x4, persist x2,
quota-subcommand x1) green, unchanged.

Metric: files > 400 LOC 95 -> 91. validate + validate:ci-parity green.
This commit is contained in:
Tam Nhu Tran committed 2026-06-18 18:48:13 -04:00
1 parent 7234ef8fcf
commit 919be3c722
44 files changed
+5935 -5020

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,92 @@
/**
* Claude window classification + display helpers.
*
* Claude quota results include multiple policy windows (5h, weekly, weekly
* per-model variants like Opus/Sonnet, overage, etc.). These helpers classify
* each window and extract the two "core usage" windows for the summary line.
*/
import type { ClaudeQuotaResult } from '../../../cliproxy/quota/quota-types';
import { pickMostRestrictiveClaudeWeeklyWindow } from '../../../cliproxy/quota/quota-fetcher-claude-normalizer';
import type { ClaudeDisplayWindow } from './types';
/** Human-readable label for a Claude window based on its rate-limit type. */
export function getClaudeWindowDisplayLabel(
window: Pick<ClaudeDisplayWindow, 'rateLimitType' | 'label'>
): string {
switch (window.rateLimitType) {
case 'five_hour':
return '5h usage limit';
case 'seven_day':
return 'Weekly usage limit';
case 'seven_day_opus':
return 'Weekly usage (Opus)';
case 'seven_day_sonnet':
return 'Weekly usage (Sonnet)';
case 'seven_day_oauth_apps':
return 'Weekly usage (OAuth apps)';
case 'seven_day_cowork':
return 'Weekly usage (Cowork)';
case 'overage':
return 'Extra usage';
default:
return window.label;
}
}
/** Convert a raw Claude quota window into the normalized display shape. */
export function toClaudeDisplayWindow(
window: ClaudeQuotaResult['windows'][number]
): ClaudeDisplayWindow {
return {
rateLimitType: window.rateLimitType,
label: window.label,
remainingPercent: window.remainingPercent,
resetAt: window.resetAt,
status: window.status,
};
}
/** Convert a coreUsage 5h/weekly sub-window into the display shape (or null). */
export function toClaudeCoreDisplayWindow(
window: NonNullable<ClaudeQuotaResult['coreUsage']>['fiveHour']
): ClaudeDisplayWindow | null {
if (!window) return null;
return {
rateLimitType: window.rateLimitType,
label: window.label,
remainingPercent: window.remainingPercent,
resetAt: window.resetAt,
status: window.status,
};
}
/**
* Pick the two "core usage" windows (5h + weekly) for a Claude result.
*
* Prefers the explicit coreUsage metadata. Falls back to the 'five_hour'
* window and the most restrictive weekly window when metadata is absent.
*/
export function getClaudeCoreUsageWindows(quota: ClaudeQuotaResult): {
fiveHourWindow: ClaudeDisplayWindow | null;
weeklyWindow: ClaudeDisplayWindow | null;
} {
const coreUsage = quota.coreUsage;
const fiveHourFromCore = toClaudeCoreDisplayWindow(coreUsage?.fiveHour ?? null);
const weeklyFromCore = toClaudeCoreDisplayWindow(coreUsage?.weekly ?? null);
if (fiveHourFromCore || weeklyFromCore) {
return {
fiveHourWindow: fiveHourFromCore,
weeklyWindow: weeklyFromCore,
};
}
const fiveHourPolicy =
quota.windows.find((window) => window.rateLimitType === 'five_hour') ?? null;
const weeklyPolicy = pickMostRestrictiveClaudeWeeklyWindow(quota.windows);
return {
fiveHourWindow: fiveHourPolicy ? toClaudeDisplayWindow(fiveHourPolicy) : null,
weeklyWindow: weeklyPolicy ? toClaudeDisplayWindow(weeklyPolicy) : null,
};
}
@@ -0,0 +1,226 @@
/**
* Codex window classification + display helpers.
*
* Codex quota results include multiple rate-limit windows (5h usage, weekly
* usage, code review, and "additional" feature windows like Codex Spark).
* These helpers classify each window, pick display labels, and identify the
* two "core usage" windows used in the per-account summary.
*/
import {
sanitizeCodexFeatureLabel,
sanitizeCodexFeatureLabelOrNull,
} from '../../../cliproxy/quota/quota-label-sanitizer';
import type { CodexQuotaResult } from '../../../cliproxy/quota/quota-types';
import { formatAbsoluteResetTime, formatResetTime, formatResetTimeISO } from './format-helpers';
import type { CodexWindowKind } from './types';
/** Subset of a Codex window used by label classification (for test ergonomics). */
export type CodexWindowSummary = Pick<
CodexQuotaResult['windows'][number],
'label' | 'resetAfterSeconds' | 'category' | 'cadence' | 'featureLabel'
>;
/** Render the reset time for a Codex window as either a relative or absolute label. */
export function formatCodexWindowReset(
window: Pick<CodexQuotaResult['windows'][number], 'resetAfterSeconds' | 'resetAt'>
): string | null {
if (typeof window.resetAfterSeconds === 'number' && isFinite(window.resetAfterSeconds)) {
const relative = formatResetTime(Math.max(0, window.resetAfterSeconds));
if (window.resetAfterSeconds >= 86400 && window.resetAt) {
const absolute = formatAbsoluteResetTime(window.resetAt);
return absolute ? `${relative} (${absolute})` : relative;
}
return relative;
}
if (window.resetAt) {
return formatResetTimeISO(window.resetAt);
}
return null;
}
/** Classify a Codex window label into a known kind. */
export function getCodexWindowKind(label: string): CodexWindowKind {
const lower = (label || '').toLowerCase();
const isCodeReview = lower.includes('code review') || lower.includes('code_review');
const isPrimary = lower.includes('primary');
const isSecondary = lower.includes('secondary');
if (isCodeReview) {
if (isPrimary) return 'code-review-5h';
if (isSecondary) return 'code-review-weekly';
return 'code-review';
}
if (isPrimary) return 'usage-5h';
if (isSecondary) return 'usage-weekly';
return 'unknown';
}
/**
* Infer whether a code-review window resets on the 5h or weekly cadence by
* comparing its reset time against the 5h and weekly usage windows. Returns
* null when no inference is possible.
*/
export function inferCodeReviewCadence(
window: CodexWindowSummary,
allWindows: CodexWindowSummary[]
): '5h' | 'weekly' | null {
const kind = getCodexWindowKind(window.label);
if (kind === 'code-review-weekly') return 'weekly';
const reset = window.resetAfterSeconds;
if (typeof reset !== 'number' || !isFinite(reset) || reset <= 0) return null;
const usage5h = allWindows.find(
(w) =>
getCodexWindowKind(w.label) === 'usage-5h' &&
typeof w.resetAfterSeconds === 'number' &&
isFinite(w.resetAfterSeconds) &&
w.resetAfterSeconds > 0
);
const usageWeekly = allWindows.find(
(w) =>
getCodexWindowKind(w.label) === 'usage-weekly' &&
typeof w.resetAfterSeconds === 'number' &&
isFinite(w.resetAfterSeconds) &&
w.resetAfterSeconds > 0
);
if (!usage5h || !usageWeekly) return null;
const diffTo5h = Math.abs(reset - (usage5h.resetAfterSeconds as number));
const diffToWeekly = Math.abs(reset - (usageWeekly.resetAfterSeconds as number));
return diffToWeekly <= diffTo5h ? 'weekly' : '5h';
}
/**
* Strip a leading "GPT-X.Y-Codex-" prefix from a feature label and turn the
* remainder into a Codex-prefixed display name. Other labels pass through unchanged.
*/
export function prettifyCodexFeatureLabel(featureLabel: unknown, fallbackLabel?: unknown): string {
const trimmed =
sanitizeCodexFeatureLabelOrNull(featureLabel) ??
(fallbackLabel === undefined
? sanitizeCodexFeatureLabel(featureLabel)
: sanitizeCodexFeatureLabel(fallbackLabel));
const stripped = trimmed.replace(/^GPT-[\d.]+-Codex-/i, '');
if (stripped !== trimmed && stripped.length > 0) {
return `Codex ${stripped}`;
}
return trimmed;
}
/** Human-readable label for a Codex window, using metadata when available. */
export function getCodexWindowDisplayLabel(
window: CodexWindowSummary,
allWindows: CodexWindowSummary[] = []
): string {
const context = allWindows.length > 0 ? allWindows : [window];
// Prefer explicit category metadata when present (post-2026-04 windows).
if (window.category === 'usage') {
if (window.cadence === '5h') return '5h usage limit';
if (window.cadence === 'weekly') return 'Weekly usage limit';
}
if (window.category === 'additional') {
const pretty = prettifyCodexFeatureLabel(window.featureLabel, window.label);
if (window.cadence === '5h') return `${pretty} (5h)`;
if (window.cadence === 'weekly') return `${pretty} (weekly)`;
return pretty;
}
if (window.category === 'code-review') {
if (window.cadence === '5h') return 'Code review (5h)';
if (window.cadence === 'weekly') return 'Code review (weekly)';
return 'Code review';
}
// Legacy fallback: classify via label sniffing for cached windows without metadata.
switch (getCodexWindowKind(window.label)) {
case 'usage-5h':
return '5h usage limit';
case 'usage-weekly':
return 'Weekly usage limit';
case 'code-review-5h':
case 'code-review-weekly':
case 'code-review': {
const inferred = inferCodeReviewCadence(window, context);
if (inferred === '5h') return 'Code review (5h)';
if (inferred === 'weekly') return 'Code review (weekly)';
return 'Code review';
}
case 'unknown':
return window.label;
}
}
/**
* Pick the two "core usage" windows (5h + weekly) out of a Codex result.
*
* Prefers explicit category metadata. Falls back to label sniffing for cached
* windows, and finally to a best-effort guess based on reset times so the
* summary line always has something useful to show.
*/
export function getCodexCoreUsageWindows(windows: CodexQuotaResult['windows']): {
fiveHourWindow: CodexQuotaResult['windows'][number] | null;
weeklyWindow: CodexQuotaResult['windows'][number] | null;
} {
let fiveHourWindow: CodexQuotaResult['windows'][number] | null = null;
let weeklyWindow: CodexQuotaResult['windows'][number] | null = null;
const nonCodeReviewWindows: CodexQuotaResult['windows'] = [];
// Prefer explicit category metadata when present so 'additional' windows
// (e.g. GPT-5.3 Codex Spark) do not displace core usage windows in the summary.
const hasCategoryMetadata = windows.some((window) => Boolean(window.category));
if (hasCategoryMetadata) {
for (const window of windows) {
if (window.category === 'usage') {
if (window.cadence === '5h' && !fiveHourWindow) fiveHourWindow = window;
else if (window.cadence === 'weekly' && !weeklyWindow) weeklyWindow = window;
nonCodeReviewWindows.push(window);
}
// 'code-review' and 'additional' are excluded from the core usage summary.
}
} else {
for (const window of windows) {
const kind = getCodexWindowKind(window.label);
if (kind === 'usage-5h') {
if (!fiveHourWindow) fiveHourWindow = window;
nonCodeReviewWindows.push(window);
continue;
}
if (kind === 'usage-weekly') {
if (!weeklyWindow) weeklyWindow = window;
nonCodeReviewWindows.push(window);
continue;
}
if (kind === 'unknown') {
nonCodeReviewWindows.push(window);
}
}
}
if ((!fiveHourWindow || !weeklyWindow) && nonCodeReviewWindows.length > 0) {
const withReset = nonCodeReviewWindows
.filter((w) => typeof w.resetAfterSeconds === 'number' && w.resetAfterSeconds >= 0)
.sort((a, b) => (a.resetAfterSeconds || 0) - (b.resetAfterSeconds || 0));
if (!fiveHourWindow) {
fiveHourWindow = withReset[0] || nonCodeReviewWindows[0] || null;
}
if (!weeklyWindow) {
weeklyWindow =
withReset.length > 1
? withReset[withReset.length - 1]
: nonCodeReviewWindows.find((w) => w !== fiveHourWindow) || null;
}
}
return { fiveHourWindow, weeklyWindow };
}
@@ -0,0 +1,87 @@
/**
* Generic formatting helpers for quota CLI output.
*
* These helpers are pure (no I/O, no side effects) so they can be unit tested
* in isolation and reused across provider sections.
*/
import { formatAccountDisplayName } from '../../../cliproxy/accounts/email-account-identity';
import { color, dim } from '../../../utils/ui';
/** Render a 20-char wide ASCII quota bar for the given percentage. */
export function formatQuotaBar(percentage: number): string {
const width = 20;
const clampedPct = Math.max(0, Math.min(100, percentage));
const filled = Math.round((clampedPct / 100) * width);
const empty = width - filled;
const filledChar = clampedPct > 50 ? '█' : clampedPct > 10 ? '▓' : '░';
return `[${filledChar.repeat(filled)}${' '.repeat(empty)}]`;
}
/** Render a human-readable relative reset time from a seconds offset. */
export function formatResetTime(seconds: number): string {
if (seconds <= 0) return 'now';
if (seconds < 60) return `in ${seconds}s`;
if (seconds < 3600) return `in ${Math.round(seconds / 60)}m`;
if (seconds < 86400) return `in ${Math.round(seconds / 3600)}h`;
const days = Math.floor(seconds / 86400);
const hours = Math.round((seconds % 86400) / 3600);
if (hours <= 0) return `in ${days}d`;
if (hours >= 24) return `in ${days + 1}d`;
return `in ${days}d ${hours}h`;
}
/** Render a relative reset time from an ISO timestamp. Returns 'unknown' if invalid. */
export function formatResetTimeISO(isoTime: string): string {
if (!isoTime) return 'unknown';
const resetDate = new Date(isoTime);
if (isNaN(resetDate.getTime())) return 'unknown';
const seconds = Math.max(0, Math.round((resetDate.getTime() - Date.now()) / 1000));
return formatResetTime(seconds);
}
/** Render an absolute reset time (MM/DD HH:MM) from ISO, or null if invalid. */
export function formatAbsoluteResetTime(isoTime: string): string | null {
if (!isoTime) return null;
const resetDate = new Date(isoTime);
if (isNaN(resetDate.getTime())) return null;
const date = resetDate.toLocaleDateString(undefined, {
month: '2-digit',
day: '2-digit',
});
const time = resetDate.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
});
return `${date} ${time}`;
}
/**
* Display label for an account: shows nickname + canonical email-style label
* when a nickname is present, otherwise just the canonical label.
*/
export function formatCliAccountLabel(account: {
id: string;
email?: string;
nickname?: string;
}): string {
const displayName = formatAccountDisplayName(account);
return account.nickname ? `${account.nickname} (${displayName})` : displayName;
}
/**
* Pick the tier to display for an account. A live (freshly fetched) tier wins
* over a stale account-config tier unless the live tier is 'unknown'. Returns
* 'unknown' when neither source provides a value.
*/
export function resolveDisplayedTier(
accountTier: string | undefined,
liveTier: string | undefined
): string {
return (liveTier && liveTier !== 'unknown' ? liveTier : accountTier) || 'unknown';
}
// Re-export dim/color here so section modules can pull UI primitives from a
// single quota-local import. Keeps the surface small.
export { color, dim };
@@ -0,0 +1,303 @@
/**
* Quota CLI subcommand handlers.
*
* Public entry points consumed by src/commands/cliproxy/index.ts:
* - handleQuotaStatus (`ccs cliproxy quota`)
* - handleDoctor (`ccs cliproxy doctor` / `diag`)
* - handleSetDefault (`ccs cliproxy default <account>`)
* - handlePauseAccount (`ccs cliproxy pause <account>`)
* - handleResumeAccount (`ccs cliproxy resume <account>`)
*
* Behavior is preserved verbatim from the original god file; only the
* module boundaries changed.
*/
import {
getProviderAccounts,
pauseAccount,
resumeAccount,
setDefaultAccount,
findAccountByQuery,
} from '../../../cliproxy/accounts/account-manager';
import type { CLIProxyProvider } from '../../../cliproxy/types';
import {
QUOTA_SUPPORTED_PROVIDER_IDS,
type QuotaSupportedProvider,
} from '../../../cliproxy/provider-capabilities';
import { fetchAllProviderQuotas } from '../../../cliproxy/quota/quota-fetcher';
import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../../../utils/ui';
import { renderProviderPoolSection, readPoolRoutingSettings } from '../pool-state-renderer';
import { displayQuotaFailure } from './quota-failure-display';
import { formatCliAccountLabel, formatQuotaBar } from './format-helpers';
import { parseProfileArgs } from './profile-args';
import { QUOTA_PROVIDER_RUNTIME } from './provider-runtime';
/** `ccs cliproxy quota [--provider <name>]` */
export async function handleQuotaStatus(
verbose = false,
providerFilter: QuotaSupportedProvider | 'all' = 'all'
): Promise<void> {
await initUI();
console.log(header('Quota Status'));
console.log('');
const requestedProviders = new Set<QuotaSupportedProvider>(
providerFilter === 'all' ? QUOTA_SUPPORTED_PROVIDER_IDS : [providerFilter]
);
const shouldFetch = (provider: QuotaSupportedProvider): boolean =>
requestedProviders.has(provider);
console.log(dim('Fetching quotas...'));
const providerResults = new Map<QuotaSupportedProvider, unknown | null>(
await Promise.all(
QUOTA_SUPPORTED_PROVIDER_IDS.map(async (provider) => {
if (!shouldFetch(provider)) {
return [provider, null] as const;
}
return [provider, await QUOTA_PROVIDER_RUNTIME[provider].fetch(verbose)] as const;
})
)
);
console.log('');
// Pool routing settings are global to the CLIProxy config; read once.
const poolSettings = readPoolRoutingSettings();
for (const provider of QUOTA_SUPPORTED_PROVIDER_IDS) {
if (!shouldFetch(provider)) {
continue;
}
const runtime = QUOTA_PROVIDER_RUNTIME[provider];
const result = providerResults.get(provider) ?? null;
if (result !== null && runtime.hasData(result)) {
runtime.render(result);
// Pool context: drain order + per-account state (available/cooling/paused).
// QuotaSupportedProvider ids are all valid CLIProxyProvider values.
// Async: folds in live in-proxy 429 cooldowns when pool routing is on.
await renderProviderPoolSection(provider as CLIProxyProvider, poolSettings);
continue;
}
console.log(subheader(runtime.emptyTitle));
console.log(info(runtime.emptyMessage));
console.log(` Run: ${color(runtime.authCommand, 'command')} to authenticate`);
console.log('');
}
}
/** `ccs cliproxy doctor` (alias: `diag`) - Antigravity diagnostics. */
export async function handleDoctor(verbose = false): Promise<void> {
await initUI();
console.log(header('CLIProxy Quota Diagnostics'));
console.log('');
const provider: CLIProxyProvider = 'agy';
const accounts = getProviderAccounts(provider);
if (accounts.length === 0) {
console.log(info('No Antigravity accounts configured'));
console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`);
return;
}
console.log(subheader(`Antigravity Accounts (${accounts.length})`));
console.log('');
console.log(dim('Fetching quotas...'));
const quotaResult = await fetchAllProviderQuotas(provider, verbose);
for (const { account, quota } of quotaResult.accounts) {
const accountLabel = formatCliAccountLabel(account);
const defaultBadge = account.isDefault ? color(' (default)', 'info') : '';
if (!quota.success) {
console.log(` ${fail(accountLabel)}${defaultBadge}`);
displayQuotaFailure(quota);
if (quota.isUnprovisioned) {
console.log(
` ${warn('Account not provisioned - open Gemini Code Assist in IDE first')}`
);
}
console.log('');
continue;
}
const avgQuota =
quota.models.length > 0
? quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length
: 0;
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
console.log(` ${statusIcon}${accountLabel}${defaultBadge}`);
if (quota.projectId) {
console.log(` Project: ${dim(quota.projectId)}`);
}
for (const model of quota.models) {
const bar = formatQuotaBar(model.percentage);
console.log(` ${model.name.padEnd(20)} ${bar} ${model.percentage.toFixed(0)}%`);
}
console.log('');
}
const sharedProjects = Object.entries(quotaResult.projectGroups).filter(
([, accountIds]) => accountIds.length > 1
);
if (sharedProjects.length > 0) {
console.log('');
console.log(subheader('Shared Project Warning'));
console.log('');
for (const [projectId, accountIds] of sharedProjects) {
console.log(
fail(`Project ${projectId.substring(0, 20)}... shared by ${accountIds.length} accounts:`)
);
for (const accountId of accountIds) {
console.log(` - ${accountId}`);
}
console.log('');
console.log(warn('These accounts share the same quota pool!'));
console.log(warn('Failover between them will NOT help when quota is exhausted.'));
console.log(info('Solution: Use accounts from different GCP projects.'));
}
}
console.log('');
console.log(subheader('Summary'));
const healthyAccounts = quotaResult.accounts.filter(
({ quota }) => quota.success && quota.models.some((m) => m.percentage > 5)
);
console.log(` Accounts with quota: ${healthyAccounts.length}/${accounts.length}`);
if (sharedProjects.length > 0) {
console.log(` ${fail(`Shared projects: ${sharedProjects.length} (failover limited)`)}`);
} else if (accounts.length > 1) {
console.log(` ${ok('No shared projects (failover fully operational)')}`);
}
console.log('');
}
/** `ccs cliproxy default <account> [--provider <provider>]` */
export async function handleSetDefault(args: string[]): Promise<void> {
await initUI();
const parsed = parseProfileArgs(args);
if (!parsed.name) {
console.log(fail('Usage: ccs cliproxy default <account> [--provider <provider>]'));
console.log('');
console.log('Examples:');
console.log(' ccs cliproxy default ultra@gmail.com');
console.log(' ccs cliproxy default john --provider agy');
process.exit(1);
}
const provider = (parsed.provider || 'agy') as CLIProxyProvider;
const account = findAccountByQuery(provider, parsed.name);
if (!account) {
console.log(fail(`Account not found: ${parsed.name}`));
console.log('');
const accounts = getProviderAccounts(provider);
if (accounts.length > 0) {
console.log('Available accounts:');
for (const acc of accounts) {
const badge = acc.isDefault ? color(' (current default)', 'info') : '';
console.log(` - ${formatCliAccountLabel(acc)}${badge}`);
}
} else {
console.log(`No accounts found for provider: ${provider}`);
console.log(`Run: ccs ${provider} --auth`);
}
process.exit(1);
}
const success = setDefaultAccount(provider, account.id);
if (success) {
console.log(ok(`Default account set to: ${formatCliAccountLabel(account)}`));
console.log(info(`Provider: ${provider}`));
} else {
console.log(fail('Failed to set default account'));
process.exit(1);
}
}
/** `ccs cliproxy pause <account> [--provider <provider>]` */
export async function handlePauseAccount(args: string[]): Promise<void> {
await initUI();
const parsed = parseProfileArgs(args);
if (!parsed.name) {
console.log(fail('Usage: ccs cliproxy pause <account> [--provider <provider>]'));
console.log('');
console.log('Pauses an account so it will be skipped in quota rotation.');
process.exit(1);
}
const provider = (parsed.provider || 'agy') as CLIProxyProvider;
const account = findAccountByQuery(provider, parsed.name);
if (!account) {
console.log(fail(`Account not found: ${parsed.name}`));
process.exit(1);
}
if (account.paused) {
const refreshed = pauseAccount(provider, account.id);
const refreshedAccount = refreshed ? findAccountByQuery(provider, account.id) : account;
console.log(warn(`Account already paused: ${formatCliAccountLabel(account)}`));
if (refreshed) {
console.log(info('Manual pause refreshed; account will stay out of quota rotation'));
}
console.log(info(`Paused at: ${refreshedAccount?.pausedAt || account.pausedAt || 'unknown'}`));
return;
}
const success = pauseAccount(provider, account.id);
if (success) {
console.log(ok(`Account paused: ${formatCliAccountLabel(account)}`));
console.log(info('Account will be skipped in quota rotation'));
} else {
console.log(fail('Failed to pause account'));
process.exit(1);
}
}
/** `ccs cliproxy resume <account> [--provider <provider>]` */
export async function handleResumeAccount(args: string[]): Promise<void> {
await initUI();
const parsed = parseProfileArgs(args);
if (!parsed.name) {
console.log(fail('Usage: ccs cliproxy resume <account> [--provider <provider>]'));
console.log('');
console.log('Resumes a paused account for quota rotation.');
process.exit(1);
}
const provider = (parsed.provider || 'agy') as CLIProxyProvider;
const account = findAccountByQuery(provider, parsed.name);
if (!account) {
console.log(fail(`Account not found: ${parsed.name}`));
process.exit(1);
}
if (!account.paused) {
console.log(warn(`Account is not paused: ${formatCliAccountLabel(account)}`));
return;
}
const success = resumeAccount(provider, account.id);
if (success) {
console.log(ok(`Account resumed: ${formatCliAccountLabel(account)}`));
console.log(info('Account is now active in quota rotation'));
} else {
console.log(fail('Failed to resume account'));
process.exit(1);
}
}
@@ -0,0 +1,30 @@
/**
* Argument parsing for account-management subcommands (default/pause/resume).
*
* Extracted verbatim from the original god file. Only the parsing logic lives
* here; the subcommand handlers themselves are in handlers.ts.
*/
import type { CliproxyProfileArgs } from './types';
/** Parse the raw CLI args for a `ccs cliproxy default|pause|resume` invocation. */
export function parseProfileArgs(args: string[]): CliproxyProfileArgs {
const result: CliproxyProfileArgs = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--provider' && args[i + 1]) {
result.provider = args[++i];
} else if (arg === '--model' && args[i + 1]) {
result.model = args[++i];
} else if (arg === '--account' && args[i + 1]) {
result.account = args[++i];
} else if (arg === '--force') {
result.force = true;
} else if (arg === '--yes' || arg === '-y') {
result.yes = true;
} else if (!arg.startsWith('-') && !result.name) {
result.name = arg;
}
}
return result;
}
@@ -0,0 +1,76 @@
/**
* Per-provider runtime adapters for the quota command.
*
* Each entry wires a fetcher (from cliproxy/quota/*) to its section renderer
* and provides the empty-state strings shown when no accounts are configured.
* The runtime map is consumed by handleQuotaStatus in handlers.ts.
*/
import type {
ClaudeQuotaResult,
CodexQuotaResult,
GeminiCliQuotaResult,
GhcpQuotaResult,
} from '../../../cliproxy/quota/quota-types';
import { fetchAllClaudeQuotas } from '../../../cliproxy/quota/quota-fetcher-claude';
import { fetchAllCodexQuotas } from '../../../cliproxy/quota/quota-fetcher-codex';
import { fetchAllGeminiCliQuotas } from '../../../cliproxy/quota/quota-fetcher-gemini-cli';
import { fetchAllGhcpQuotas } from '../../../cliproxy/quota/quota-fetcher-ghcp';
import { fetchAllProviderQuotas } from '../../../cliproxy/quota/quota-fetcher';
import type { QuotaSupportedProvider } from '../../../cliproxy/provider-capabilities';
import type { QuotaProviderRuntime } from './types';
import { displayAntigravityQuotaSection } from './sections/antigravity';
import { displayClaudeQuotaSection } from './sections/claude';
import { displayCodexQuotaSection } from './sections/codex';
import { displayGhcpQuotaSection } from './sections/ghcp';
import { displayGeminiCliQuotaSection } from './sections/gemini-cli';
/** Runtime adapter for each quota-supported provider. */
export const QUOTA_PROVIDER_RUNTIME: Record<QuotaSupportedProvider, QuotaProviderRuntime> = {
agy: {
fetch: (verbose) => fetchAllProviderQuotas('agy', verbose),
hasData: (result) =>
(result as Awaited<ReturnType<typeof fetchAllProviderQuotas>>).accounts.length > 0,
render: (result) =>
displayAntigravityQuotaSection(result as Awaited<ReturnType<typeof fetchAllProviderQuotas>>),
emptyTitle: 'Antigravity (0 accounts)',
emptyMessage: 'No Antigravity accounts configured',
authCommand: 'ccs agy --auth',
},
codex: {
fetch: (verbose) => fetchAllCodexQuotas(verbose),
hasData: (result) => (result as { account: string; quota: CodexQuotaResult }[]).length > 0,
render: (result) =>
displayCodexQuotaSection(result as { account: string; quota: CodexQuotaResult }[]),
emptyTitle: 'Codex (0 accounts)',
emptyMessage: 'No Codex accounts configured',
authCommand: 'ccs codex --auth',
},
claude: {
fetch: (verbose) => fetchAllClaudeQuotas(verbose),
hasData: (result) => (result as { account: string; quota: ClaudeQuotaResult }[]).length > 0,
render: (result) =>
displayClaudeQuotaSection(result as { account: string; quota: ClaudeQuotaResult }[]),
emptyTitle: 'Claude (0 accounts)',
emptyMessage: 'No Claude accounts configured',
authCommand: 'ccs claude --auth',
},
gemini: {
fetch: (verbose) => fetchAllGeminiCliQuotas(verbose),
hasData: (result) => (result as { account: string; quota: GeminiCliQuotaResult }[]).length > 0,
render: (result) =>
displayGeminiCliQuotaSection(result as { account: string; quota: GeminiCliQuotaResult }[]),
emptyTitle: 'Gemini CLI (0 accounts)',
emptyMessage: 'No Gemini CLI accounts configured',
authCommand: 'ccs gemini --auth',
},
ghcp: {
fetch: (verbose) => fetchAllGhcpQuotas(verbose),
hasData: (result) => (result as { account: string; quota: GhcpQuotaResult }[]).length > 0,
render: (result) =>
displayGhcpQuotaSection(result as { account: string; quota: GhcpQuotaResult }[]),
emptyTitle: 'GitHub Copilot (0 accounts)',
emptyMessage: 'No GitHub Copilot accounts configured',
authCommand: 'ccs ghcp --auth',
},
};
@@ -0,0 +1,86 @@
/**
* Quota failure display helpers.
*
* Builds the multi-line failure block shown beneath a failed account row.
* Extracted from the original god file verbatim so the CLI output and the
* unit tests in tests/unit/commands/cliproxy-quota-subcommand.test.ts keep
* their exact behavior.
*/
import type { QuotaErrorMetadata } from '../../../cliproxy/quota/quota-types';
import { color, dim, info } from '../../../utils/ui';
import type { QuotaFailureDisplayEntry } from './types';
/**
* Build the ordered list of failure display entries for a quota error.
*
* Order is:
* 1. error message (always)
* 2. action hint (if present)
* 3. diagnostics line: HTTP status | error code | retryable flag (if any)
* 4. detail line (only if it differs from the error message)
*/
export function getQuotaFailureDisplayEntries(
quota: QuotaErrorMetadata & {
error?: string;
}
): QuotaFailureDisplayEntry[] {
const entries: QuotaFailureDisplayEntry[] = [
{
tone: 'error',
text: quota.error || 'Failed to fetch quota',
},
];
if (quota.actionHint) {
entries.push({
tone: 'info',
text: quota.actionHint,
});
}
const diagnostics: string[] = [];
if (typeof quota.httpStatus === 'number') {
diagnostics.push(`HTTP ${quota.httpStatus}`);
}
if (quota.errorCode) {
diagnostics.push(`Code: ${quota.errorCode}`);
}
if (quota.retryable) {
diagnostics.push('Retryable');
}
if (diagnostics.length > 0) {
entries.push({
tone: 'dim',
text: diagnostics.join(' | '),
});
}
const normalizedError = quota.error?.trim();
const normalizedDetail = quota.errorDetail?.trim();
if (normalizedDetail && normalizedDetail !== normalizedError) {
entries.push({
tone: 'dim',
text: `Detail: ${normalizedDetail}`,
});
}
return entries;
}
/** Render the failure block for a single failed account to stdout. */
export function displayQuotaFailure(
quota: QuotaErrorMetadata & {
error?: string;
}
): void {
for (const entry of getQuotaFailureDisplayEntries(quota)) {
const rendered =
entry.tone === 'error'
? color(entry.text, 'error')
: entry.tone === 'info'
? info(entry.text)
: dim(entry.text);
console.log(` ${rendered}`);
}
}
@@ -0,0 +1,57 @@
/**
* Antigravity (agy) provider section renderer for `ccs cliproxy quota`.
*
* Renders the account table with per-account average quota, tier, and status
* (paused / cooldown). Extracted verbatim from the original god file.
*/
import { getProviderAccounts } from '../../../../cliproxy/accounts/account-manager';
import { fetchAllProviderQuotas } from '../../../../cliproxy/quota/quota-fetcher';
import { isOnCooldown } from '../../../../cliproxy/quota/quota-manager';
import { color, subheader, table } from '../../../../utils/ui';
import { formatCliAccountLabel, resolveDisplayedTier } from '../format-helpers';
/** Render the Antigravity quota section for a fetched quota result. */
export function displayAntigravityQuotaSection(
quotaResult: Awaited<ReturnType<typeof fetchAllProviderQuotas>>
): void {
const provider = 'agy';
const accounts = getProviderAccounts(provider);
console.log(
subheader(`Antigravity (${accounts.length} account${accounts.length !== 1 ? 's' : ''})`)
);
console.log('');
const rows: string[][] = [];
for (const account of accounts) {
const quotaData = quotaResult.accounts.find((q) => q.account.id === account.id);
const quota = quotaData?.quota;
let avgQuota = 'N/A';
if (quota?.success && quota.models.length > 0) {
const avg = Math.round(
quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length
);
avgQuota = `${avg}%`;
}
const statusParts: string[] = [];
if (account.paused) statusParts.push(color('PAUSED', 'warning'));
if (isOnCooldown(provider, account.id)) statusParts.push(color('COOLDOWN', 'warning'));
const defaultMark = account.isDefault ? color('*', 'success') : ' ';
const tier = resolveDisplayedTier(account.tier, quota?.entitlement?.normalizedTier);
const status = statusParts.join(', ');
rows.push([defaultMark, formatCliAccountLabel(account), tier, avgQuota, status]);
}
console.log(
table(rows, {
head: ['', 'Account', 'Tier', 'Quota', 'Status'],
colWidths: [3, 30, 10, 10, 20],
})
);
console.log('');
}
@@ -0,0 +1,99 @@
/**
* Claude provider section renderer for `ccs cliproxy quota`.
*
* Renders per-account quota bars for the 5h + weekly core usage windows plus
* any per-model or overage windows. Extracted verbatim from the original
* god file.
*/
import { findAccountByQuery } from '../../../../cliproxy/accounts/account-manager';
import type { ClaudeQuotaResult } from '../../../../cliproxy/quota/quota-types';
import { color, dim, fail, info, ok, subheader, warn } from '../../../../utils/ui';
import {
getClaudeCoreUsageWindows,
getClaudeWindowDisplayLabel,
toClaudeDisplayWindow,
} from '../claude-window-helpers';
import { displayQuotaFailure } from '../quota-failure-display';
import { formatCliAccountLabel, formatQuotaBar, formatResetTimeISO } from '../format-helpers';
import type { ClaudeDisplayWindow } from '../types';
/** Render the Claude quota section for a list of per-account results. */
export function displayClaudeQuotaSection(
results: {
account: string;
quota: ClaudeQuotaResult;
}[]
): void {
console.log(subheader(`Claude (${results.length} account${results.length !== 1 ? 's' : ''})`));
console.log('');
for (const { account, quota } of results) {
const accountInfo = findAccountByQuery('claude', account);
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
if (!quota.success) {
console.log(` ${fail(accountLabel)}${defaultMark}`);
displayQuotaFailure(quota);
console.log('');
continue;
}
const { fiveHourWindow, weeklyWindow } = getClaudeCoreUsageWindows(quota);
const coreWindows = [fiveHourWindow, weeklyWindow].filter(
(window, index, arr): window is ClaudeDisplayWindow =>
!!window && arr.indexOf(window) === index
);
const statusWindows =
coreWindows.length > 0 ? coreWindows : quota.windows.map(toClaudeDisplayWindow);
const minQuota =
statusWindows.length > 0
? Math.min(...statusWindows.map((window) => window.remainingPercent))
: null;
const statusIcon =
minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
console.log(` ${statusIcon}${accountLabel}${defaultMark}`);
const resetParts: string[] = [];
if (fiveHourWindow?.resetAt)
resetParts.push(`5h ${formatResetTimeISO(fiveHourWindow.resetAt)}`);
if (weeklyWindow?.resetAt)
resetParts.push(`weekly ${formatResetTimeISO(weeklyWindow.resetAt)}`);
if (resetParts.length > 0) {
console.log(` ${dim(`Reset schedule: ${resetParts.join(' | ')}`)}`);
}
const orderedWindows = [...coreWindows, ...quota.windows.map(toClaudeDisplayWindow)].filter(
(window, index, arr) =>
arr.findIndex(
(candidate) =>
candidate.rateLimitType === window.rateLimitType &&
candidate.resetAt === window.resetAt &&
candidate.status === window.status
) === index
);
if (orderedWindows.length === 0) {
console.log(` ${dim('Policy limits unavailable for this account')}`);
console.log('');
continue;
}
for (const window of orderedWindows) {
const bar = formatQuotaBar(window.remainingPercent);
const resetLabel = window.resetAt ? dim(` Resets ${formatResetTimeISO(window.resetAt)}`) : '';
const statusLabel =
window.status === 'rejected'
? dim(' [blocked]')
: window.status === 'allowed_warning'
? dim(' [warning]')
: '';
console.log(
` ${getClaudeWindowDisplayLabel(window).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${statusLabel}${resetLabel}`
);
}
console.log('');
}
}
@@ -0,0 +1,102 @@
/**
* Codex provider section renderer for `ccs cliproxy quota`.
*
* Renders per-account quota bars for the 5h + weekly core usage windows plus
* any additional feature windows (e.g. Codex Spark). Extracted verbatim from
* the original god file.
*/
import { findAccountByQuery } from '../../../../cliproxy/accounts/account-manager';
import type { CodexQuotaResult } from '../../../../cliproxy/quota/quota-types';
import { color, dim, fail, ok, subheader, warn } from '../../../../utils/ui';
import {
formatCodexWindowReset,
getCodexCoreUsageWindows,
getCodexWindowDisplayLabel,
} from '../codex-window-helpers';
import { displayQuotaFailure } from '../quota-failure-display';
import { formatCliAccountLabel, formatQuotaBar } from '../format-helpers';
/** Render the Codex quota section for a list of per-account results. */
export function displayCodexQuotaSection(
results: {
account: string;
quota: CodexQuotaResult;
}[]
): void {
console.log(subheader(`Codex (${results.length} account${results.length !== 1 ? 's' : ''})`));
console.log('');
for (const { account, quota } of results) {
const accountInfo = findAccountByQuery('codex', account);
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
if (!quota.success) {
console.log(` ${fail(accountLabel)}${defaultMark}`);
displayQuotaFailure(quota);
console.log('');
continue;
}
const { fiveHourWindow, weeklyWindow } = getCodexCoreUsageWindows(quota.windows);
const coreUsageWindows = [fiveHourWindow, weeklyWindow].filter(
(w, index, arr): w is NonNullable<typeof w> => !!w && arr.indexOf(w) === index
);
const statusWindows = coreUsageWindows.length > 0 ? coreUsageWindows : quota.windows;
const avgQuota =
statusWindows.length > 0
? statusWindows.reduce((sum, w) => sum + w.remainingPercent, 0) / statusWindows.length
: 0;
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`);
const coreUsageSummary = quota.coreUsage ?? {
fiveHour: fiveHourWindow
? {
label: fiveHourWindow.label,
remainingPercent: fiveHourWindow.remainingPercent,
resetAfterSeconds: fiveHourWindow.resetAfterSeconds,
resetAt: fiveHourWindow.resetAt,
}
: null,
weekly: weeklyWindow
? {
label: weeklyWindow.label,
remainingPercent: weeklyWindow.remainingPercent,
resetAfterSeconds: weeklyWindow.resetAfterSeconds,
resetAt: weeklyWindow.resetAt,
}
: null,
};
const resetParts: string[] = [];
const fiveHourReset = coreUsageSummary.fiveHour
? formatCodexWindowReset(coreUsageSummary.fiveHour)
: null;
const weeklyReset = coreUsageSummary.weekly
? formatCodexWindowReset(coreUsageSummary.weekly)
: null;
if (fiveHourReset) resetParts.push(`5h ${fiveHourReset}`);
if (weeklyReset) resetParts.push(`weekly ${weeklyReset}`);
if (resetParts.length > 0) {
console.log(` ${dim(`Reset schedule: ${resetParts.join(' | ')}`)}`);
}
const orderedWindows = [fiveHourWindow, weeklyWindow, ...quota.windows].filter(
(w, index, arr): w is NonNullable<typeof w> => !!w && arr.indexOf(w) === index
);
for (const window of orderedWindows) {
const bar = formatQuotaBar(window.remainingPercent);
const resetValue = formatCodexWindowReset(window);
const resetLabel = resetValue ? dim(` Resets ${resetValue}`) : '';
console.log(
` ${getCodexWindowDisplayLabel(window, orderedWindows).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}`
);
}
console.log('');
}
}
@@ -0,0 +1,75 @@
/**
* Gemini CLI provider section renderer for `ccs cliproxy quota`.
*
* Renders per-account quota bars for each bucket (requests, tokens, etc.)
* plus project, tier, and credit balance metadata. Extracted verbatim from
* the original god file.
*/
import { findAccountByQuery } from '../../../../cliproxy/accounts/account-manager';
import type { GeminiCliQuotaResult } from '../../../../cliproxy/quota/quota-types';
import { color, dim, fail, ok, subheader, warn } from '../../../../utils/ui';
import { displayQuotaFailure } from '../quota-failure-display';
import { formatCliAccountLabel, formatQuotaBar, formatResetTimeISO } from '../format-helpers';
/** Render the Gemini CLI quota section for a list of per-account results. */
export function displayGeminiCliQuotaSection(
results: {
account: string;
quota: GeminiCliQuotaResult;
}[]
): void {
console.log(
subheader(`Gemini CLI (${results.length} account${results.length !== 1 ? 's' : ''})`)
);
console.log('');
for (const { account, quota } of results) {
const accountInfo = findAccountByQuery('gemini', account);
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
if (!quota.success) {
console.log(` ${fail(accountLabel)}${defaultMark}`);
displayQuotaFailure(quota);
console.log('');
continue;
}
const avgQuota =
quota.buckets.length > 0
? quota.buckets.reduce((sum, b) => sum + b.remainingPercent, 0) / quota.buckets.length
: 0;
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
console.log(` ${statusIcon}${accountLabel}${defaultMark}`);
if (quota.projectId) {
console.log(` Project: ${dim(quota.projectId)}`);
}
if (quota.tierLabel) {
console.log(` Tier: ${dim(quota.tierLabel)}`);
}
if (quota.entitlement?.rawTierId) {
console.log(` Tier ID: ${dim(quota.entitlement.rawTierId)}`);
}
if (quota.creditBalance !== null && quota.creditBalance !== undefined) {
console.log(` Credits: ${dim(quota.creditBalance.toLocaleString())}`);
}
for (const bucket of quota.buckets) {
const bar = formatQuotaBar(bucket.remainingPercent);
const tokenLabel = bucket.tokenType ? dim(` (${bucket.tokenType})`) : '';
const amountLabel =
bucket.remainingAmount !== null && bucket.remainingAmount !== undefined
? dim(` ${bucket.remainingAmount.toLocaleString()} left`)
: '';
const resetLabel = bucket.resetTime
? dim(` Resets ${formatResetTimeISO(bucket.resetTime)}`)
: '';
console.log(
` ${bucket.label.padEnd(24)} ${bar} ${bucket.remainingPercent.toFixed(0)}%${tokenLabel}${amountLabel}${resetLabel}`
);
}
console.log('');
}
}
@@ -0,0 +1,82 @@
/**
* GitHub Copilot (ghcp) provider section renderer for `ccs cliproxy quota`.
*
* Renders per-account quota bars for premium interactions, chat, and
* completions snapshots. Extracted verbatim from the original god file.
*/
import { findAccountByQuery } from '../../../../cliproxy/accounts/account-manager';
import type { GhcpQuotaResult } from '../../../../cliproxy/quota/quota-types';
import { color, dim, fail, info, ok, subheader, warn } from '../../../../utils/ui';
import { displayQuotaFailure } from '../quota-failure-display';
import { formatCliAccountLabel, formatQuotaBar, formatResetTimeISO } from '../format-helpers';
/** Format a single snapshot as a "used/entitlement" or "N% used (unlimited)" label. */
function formatSnapshotLabel(
snapshot: GhcpQuotaResult['snapshots'][keyof GhcpQuotaResult['snapshots']]
): string {
if (snapshot.unlimited) {
return `${snapshot.percentUsed.toFixed(0)}% used (unlimited)`;
}
return `${snapshot.used}/${snapshot.entitlement} used`;
}
/** Render the GitHub Copilot quota section for a list of per-account results. */
export function displayGhcpQuotaSection(
results: { account: string; quota: GhcpQuotaResult }[]
): void {
console.log(
subheader(`GitHub Copilot (${results.length} account${results.length !== 1 ? 's' : ''})`)
);
console.log('');
for (const { account, quota } of results) {
const accountInfo = findAccountByQuery('ghcp', account);
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
if (!quota.success) {
console.log(` ${fail(accountLabel)}${defaultMark}`);
displayQuotaFailure(quota);
console.log('');
continue;
}
const reportedSnapshots = [
quota.snapshots.premiumInteractions,
quota.snapshots.chat,
quota.snapshots.completions,
].filter((snapshot) => snapshot.reported !== false);
const rows = reportedSnapshots.map((snapshot) =>
snapshot.unlimited ? 100 : snapshot.percentRemaining
);
const minQuota = rows.length > 0 ? Math.min(...rows) : null;
const statusIcon =
minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`);
if (quota.quotaResetDate) {
console.log(` ${dim(`Resets ${formatResetTimeISO(quota.quotaResetDate)}`)}`);
}
const allItems: Array<
[string, GhcpQuotaResult['snapshots'][keyof GhcpQuotaResult['snapshots']]]
> = [
['Premium interactions', quota.snapshots.premiumInteractions],
['Chat', quota.snapshots.chat],
['Completions', quota.snapshots.completions],
];
const items = allItems.filter(([, snapshot]) => snapshot.reported !== false);
for (const [label, snapshot] of items) {
const bar = formatQuotaBar(snapshot.percentRemaining);
const usageLabel = dim(` ${formatSnapshotLabel(snapshot)}`);
console.log(
` ${label.padEnd(24)} ${bar} ${snapshot.percentRemaining.toFixed(0)}%${usageLabel}`
);
}
console.log('');
}
}
@@ -0,0 +1,20 @@
/**
* Internal test-only exports for the quota subcommand.
*
* The unit test at tests/unit/commands/cliproxy-quota-subcommand.test.ts loads
* the barrel module and reads `__testExports` to exercise pure helpers. Keep
* this surface stable: adding a key is fine, but removing or renaming one
* will break the test.
*/
import { getCodexWindowDisplayLabel } from './codex-window-helpers';
import { getQuotaFailureDisplayEntries } from './quota-failure-display';
import { prettifyCodexFeatureLabel } from './codex-window-helpers';
import { resolveDisplayedTier } from './format-helpers';
export const __testExports = {
getCodexWindowDisplayLabel,
getQuotaFailureDisplayEntries,
prettifyCodexFeatureLabel,
resolveDisplayedTier,
};
@@ -0,0 +1,53 @@
/**
* Shared types for the quota-subcommand split.
*
* These types are implementation details of the quota CLI but are exposed via
* the barrel so submodules can avoid circular imports.
*/
/** Arguments accepted by account-management subcommands (default/pause/resume). */
export interface CliproxyProfileArgs {
name?: string;
provider?: string;
model?: string;
account?: string;
force?: boolean;
yes?: boolean;
}
/** Tone of a single quota-failure display line. Drives coloring. */
export type QuotaFailureDisplayTone = 'error' | 'info' | 'dim';
/** A single rendered line in a quota failure block. */
export interface QuotaFailureDisplayEntry {
tone: QuotaFailureDisplayTone;
text: string;
}
/** Normalized shape of a Claude window used by the CLI renderer. */
export interface ClaudeDisplayWindow {
rateLimitType: string;
label: string;
remainingPercent: number;
resetAt: string | null;
status: string;
}
/** Coarse classification of a Codex rate-limit window label. */
export type CodexWindowKind =
| 'usage-5h'
| 'usage-weekly'
| 'code-review-5h'
| 'code-review-weekly'
| 'code-review'
| 'unknown';
/** Runtime adapter that knows how to fetch/render a single quota provider. */
export interface QuotaProviderRuntime {
fetch: (verbose: boolean) => Promise<unknown>;
hasData: (result: unknown) => boolean;
render: (result: unknown) => void;
emptyTitle: string;
emptyMessage: string;
authCommand: string;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,99 @@
/**
* Persist Command - Argument Parsing
*
* Parses the raw CLI argv array for `ccs persist` into a typed
* PersistCommandArgs object. Owns permission-mode validation and unknown
* flag detection.
*/
import { extractOption, hasAnyFlag } from '../arg-extractor';
import {
PERSIST_KNOWN_FLAGS,
VALID_PERMISSION_MODES,
type PersistCommandArgs,
type PermissionMode,
} from './types';
export function isPermissionMode(value: string): value is PermissionMode {
return VALID_PERMISSION_MODES.includes(value as PermissionMode);
}
export function isKnownPersistFlagToken(token: string): boolean {
return PERSIST_KNOWN_FLAGS.some((flag) => token === flag || token.startsWith(`${flag}=`));
}
export function resolvePermissionMode(parsedArgs: PersistCommandArgs): PermissionMode | undefined {
if (!parsedArgs.dangerouslySkipPermissions) {
return parsedArgs.permissionMode;
}
if (parsedArgs.permissionMode && parsedArgs.permissionMode !== 'bypassPermissions') {
throw new Error(
'--dangerously-skip-permissions conflicts with --permission-mode. Use bypassPermissions or remove one flag.'
);
}
return 'bypassPermissions';
}
/** Parse command line arguments */
export function parseArgs(args: string[]): PersistCommandArgs {
const result: PersistCommandArgs = {
yes: hasAnyFlag(args, ['--yes', '-y']),
listBackups: hasAnyFlag(args, ['--list-backups']),
};
const restoreOption = extractOption(args, ['--restore']);
if (restoreOption.found) {
result.restore = restoreOption.missingValue ? true : restoreOption.value || true;
}
const permissionModeOption = extractOption(restoreOption.remainingArgs, ['--permission-mode'], {
knownFlags: PERSIST_KNOWN_FLAGS,
});
if (permissionModeOption.found) {
if (permissionModeOption.missingValue) {
result.parseError = 'Missing value for --permission-mode';
} else if (permissionModeOption.value) {
if (!isPermissionMode(permissionModeOption.value)) {
result.parseError = `Invalid --permission-mode "${permissionModeOption.value}". Valid modes: ${VALID_PERMISSION_MODES.join(', ')}`;
} else {
result.permissionMode = permissionModeOption.value;
}
}
}
result.dangerouslySkipPermissions = hasAnyFlag(permissionModeOption.remainingArgs, [
'--dangerously-skip-permissions',
'--auto-approve',
]);
const unknownFlags = permissionModeOption.remainingArgs.filter(
(arg) => arg.startsWith('-') && !isKnownPersistFlagToken(arg)
);
if (!result.parseError && unknownFlags.length > 0) {
const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', ');
result.parseError = `Unknown option(s): ${unknownList}. Run 'ccs persist --help' for usage.`;
}
if (!result.parseError && result.listBackups && result.restore) {
result.parseError = '--list-backups cannot be used with --restore';
}
if (
!result.parseError &&
(result.listBackups || result.restore) &&
(result.permissionMode || result.dangerouslySkipPermissions)
) {
result.parseError =
'Permission flags are not valid with backup operations. Use them only with ccs persist <profile>.';
}
for (const arg of permissionModeOption.remainingArgs) {
if (!arg.startsWith('-')) {
result.profile = arg;
break;
}
}
return result;
}
@@ -0,0 +1,258 @@
/**
* Persist Command - Backup Rotation & Restore
*
* Handles settings.json backup file lifecycle: creation, timestamp-based
* rotation, listing, and restore-with-rollback. Owns the --list-backups and
* --restore subcommands.
*/
import * as fs from 'fs';
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 {
formatDisplayPath,
getClaudeSettingsDisplayPath,
getNoFollowFlag,
isSymlinkAsync,
parseSettingsObject,
pathExists,
readFileUtf8NoFollow,
withPersistSettingsLock,
writeClaudeSettings,
} from './secure-file';
/** Maximum number of backups to keep (oldest are deleted) */
export const MAX_BACKUPS = 10;
export interface BackupFile {
path: string;
timestamp: string;
date: Date;
}
function parseBackupTimestamp(timestamp: string): Date | null {
const year = parseInt(timestamp.slice(0, 4), 10);
const month = parseInt(timestamp.slice(4, 6), 10);
const day = parseInt(timestamp.slice(6, 8), 10);
const hour = parseInt(timestamp.slice(9, 11), 10);
const minute = parseInt(timestamp.slice(11, 13), 10);
const second = parseInt(timestamp.slice(13, 15), 10);
const date = new Date(year, month - 1, day, hour, minute, second);
if (date.getFullYear() !== year) return null;
if (date.getMonth() !== month - 1) return null;
if (date.getDate() !== day) return null;
if (date.getHours() !== hour) return null;
if (date.getMinutes() !== minute) return null;
if (date.getSeconds() !== second) return null;
return date;
}
/** Get all backup files sorted by date (newest first) */
export function getBackupFiles(): BackupFile[] {
const settingsPath = getClaudeSettingsPath();
const dir = path.dirname(settingsPath);
if (!fs.existsSync(dir)) {
return [];
}
const backupPattern = /^settings\.json\.backup\.(\d{8}_\d{6})$/;
const files = fs
.readdirSync(dir)
.filter((f) => backupPattern.test(f))
.map((f) => {
const match = f.match(backupPattern);
if (!match) return null;
const timestamp = match[1];
const date = parseBackupTimestamp(timestamp);
if (!date) return null;
return {
path: path.join(dir, f),
timestamp,
date,
};
})
.filter((f): f is BackupFile => f !== null)
.sort((a, b) => b.date.getTime() - a.date.getTime()); // newest first
return files;
}
/** Create backup of settings.json with proper permissions and rotation */
export async function createBackup(): Promise<string> {
const settingsPath = getClaudeSettingsPath();
if (!(await pathExists(settingsPath))) {
throw new Error('No settings.json to backup');
}
const settingsContent = await readFileUtf8NoFollow(settingsPath);
const now = new Date();
const timestamp =
now.getFullYear().toString() +
(now.getMonth() + 1).toString().padStart(2, '0') +
now.getDate().toString().padStart(2, '0') +
'_' +
now.getHours().toString().padStart(2, '0') +
now.getMinutes().toString().padStart(2, '0') +
now.getSeconds().toString().padStart(2, '0');
const backupPath = `${settingsPath}.backup.${timestamp}`;
const flags =
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | getNoFollowFlag();
let handle: fs.promises.FileHandle | undefined;
try {
handle = await fs.promises.open(backupPath, flags, 0o600);
await handle.writeFile(settingsContent, { encoding: 'utf8' });
await handle.sync();
} finally {
if (handle) {
await handle.close();
}
}
try {
await fs.promises.chmod(backupPath, 0o600);
} catch {
// Best-effort permission hardening.
}
// Cleanup: Rotate old backups (keep only MAX_BACKUPS)
cleanupOldBackups();
return backupPath;
}
/** Remove old backups keeping only MAX_BACKUPS most recent */
function cleanupOldBackups(): void {
const backups = getBackupFiles();
if (backups.length > MAX_BACKUPS) {
const toDelete = backups.slice(MAX_BACKUPS);
for (const backup of toDelete) {
try {
fs.unlinkSync(backup.path);
} catch (error) {
console.log(
warn(
`Failed to delete old backup ${formatDisplayPath(backup.path)}: ${(error as Error).message}`
)
);
}
}
}
}
/** Handle --list-backups flag */
export async function handleListBackups(): Promise<void> {
await initUI();
const backups = getBackupFiles();
if (backups.length === 0) {
console.log(info('No backups found'));
return;
}
console.log(header('Available Backups'));
console.log('');
backups.forEach((b, i) => {
const dateStr = b.date.toLocaleString();
const marker = i === 0 ? color(' (latest)', 'success') : '';
console.log(` ${color(b.timestamp, 'command')} ${dim(dateStr)}${marker}`);
});
console.log('');
console.log(dim('To restore: ccs persist --restore [timestamp]'));
}
/** Handle --restore [timestamp] flag */
export async function handleRestore(timestamp: string | boolean, yes: boolean): Promise<void> {
await initUI();
const backups = getBackupFiles();
if (backups.length === 0) {
console.log(fail('No backups found'));
process.exit(1);
}
// Find backup to restore
let backup: BackupFile;
if (timestamp === true) {
// Use latest
backup = backups[0];
} else {
const found = backups.find((b) => b.timestamp === timestamp);
if (!found) {
console.log(fail(`Backup not found: ${timestamp}`));
console.log('');
console.log('Available backups:');
backups.slice(0, 5).forEach((b) => console.log(` ${b.timestamp}`));
process.exit(1);
}
backup = found;
}
console.log(header('Restore Backup'));
console.log('');
console.log(`Backup: ${color(backup.timestamp, 'command')}`);
console.log(`Date: ${backup.date.toLocaleString()}`);
console.log('');
console.log(warn(`This will replace ${getClaudeSettingsDisplayPath()}`));
console.log('');
if (!yes) {
const proceed = await InteractivePrompt.confirm('Proceed with restore?', { default: false });
if (!proceed) {
console.log(info('Cancelled'));
process.exit(0);
}
}
let parsedBackupSettings: Record<string, unknown>;
try {
const backupContent = await readFileUtf8NoFollow(backup.path);
parsedBackupSettings = parseSettingsObject(backupContent, 'Backup file');
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === 'ENOENT') {
console.log(fail('Backup was deleted during restore'));
process.exit(1);
}
if (nodeError.code === 'ELOOP') {
console.log(fail('Backup file is a symlink - refusing to restore for security'));
process.exit(1);
}
console.log(fail(`Backup file is corrupted: ${(error as Error).message}`));
process.exit(1);
}
try {
await withPersistSettingsLock(async () => {
const settingsPath = getClaudeSettingsPath();
if (await isSymlinkAsync(settingsPath)) {
throw new Error('settings.json is a symlink - refusing to restore for security');
}
let rollbackBackupPath: string | null = null;
if (await pathExists(settingsPath)) {
rollbackBackupPath = await createBackup();
}
try {
await writeClaudeSettings(parsedBackupSettings);
} catch (error) {
const writeError = error as Error;
if (rollbackBackupPath) {
try {
const rollbackContent = await readFileUtf8NoFollow(rollbackBackupPath);
const rollbackSettings = parseSettingsObject(rollbackContent, 'Rollback backup');
await writeClaudeSettings(rollbackSettings);
} catch (rollbackError) {
throw new Error(
`Restore failed: ${writeError.message}. Rollback also failed: ${(rollbackError as Error).message}. Manual recovery backup: ${formatDisplayPath(rollbackBackupPath)}`
);
}
}
throw new Error(`Restore failed: ${writeError.message}`);
}
});
} catch (error) {
console.log(fail((error as Error).message));
process.exit(1);
}
console.log(ok(`Restored from backup: ${backup.timestamp}`));
}
+256
View File
@@ -0,0 +1,256 @@
/**
* Persist Command - Main Handler
*
* Orchestrates the `ccs persist` command: dispatches to help/list/restore
* subcommands, otherwise resolves a profile, previews writes, takes a backup
* (optional), and atomically writes settings.json under a settings-dir lock.
*/
import * as fs from 'fs';
import { initUI, header, color, dim, ok, fail, warn, info } from '../../utils/ui';
import { InteractivePrompt } from '../../utils/prompt';
import ProfileDetector from '../../auth/profile-detector';
import { getClaudeSettingsPath } from '../../utils/claude-config-path';
import { parseArgs, resolvePermissionMode } from './arg-parsing';
import { showHelp } from './help';
import { handleListBackups, handleRestore, createBackup } from './backup-rotation';
import {
formatDisplayPath,
getClaudeSettingsDisplayPath,
pathExists,
readClaudeSettings,
withPersistSettingsLock,
writeClaudeSettings,
} from './secure-file';
import { isSensitiveEnvKey, maskApiKey } from './secret-detection';
import { buildPersistReceipt, printPersistReceipt, resolveProfileEnvVars } from './receipt';
import type { ResolvedEnv } from './types';
/** Main persist command handler */
export async function handlePersistCommand(args: string[]): Promise<void> {
// Check for help first
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
await showHelp();
return;
}
const parsedArgs = parseArgs(args);
if (parsedArgs.parseError) {
throw new Error(parsedArgs.parseError);
}
// Handle --list-backups
if (parsedArgs.listBackups) {
await handleListBackups();
return;
}
// Handle --restore
if (parsedArgs.restore) {
await handleRestore(parsedArgs.restore, parsedArgs.yes ?? false);
return;
}
await initUI();
const resolvedPermissionMode = resolvePermissionMode(parsedArgs);
if (!parsedArgs.profile) {
console.log(fail('Profile name is required'));
console.log('');
console.log('Usage:');
console.log(` ${color('ccs persist <profile>', 'command')}`);
console.log('');
console.log('Run for help:');
console.log(` ${color('ccs persist --help', 'command')}`);
process.exit(1);
}
// Detect profile
const detector = new ProfileDetector();
try {
detector.detectProfileType(parsedArgs.profile);
} catch (error) {
const err = error as Error & { availableProfiles?: string };
console.log(fail(`Profile not found: ${parsedArgs.profile}`));
console.log('');
if (err.availableProfiles) {
console.log(err.availableProfiles);
}
process.exit(1);
}
// Resolve env vars
let resolved: ResolvedEnv;
try {
resolved = await resolveProfileEnvVars(parsedArgs.profile);
} catch (error) {
console.log(fail((error as Error).message));
process.exit(1);
}
// Display what will be written
console.log(header(`Persist Profile: ${parsedArgs.profile}`));
console.log('');
console.log(`Profile type: ${color(resolved.profileType, 'command')}`);
console.log('');
const envKeys = Object.keys(resolved.env);
if (envKeys.length > 0) {
console.log(`The following env vars will be written to ${getClaudeSettingsDisplayPath()}:`);
console.log('');
const maxKeyLen = Math.max(...envKeys.map((k) => k.length));
for (const [key, value] of Object.entries(resolved.env)) {
const paddedKey = key.padEnd(maxKeyLen + 2);
const displayValue = isSensitiveEnvKey(key) ? maskApiKey(value) : value;
console.log(` ${color(paddedKey, 'command')} = ${displayValue}`);
}
console.log('');
} else {
console.log(info('No new env vars will be added.'));
console.log(dim(' CCS-managed transport overrides will be removed if present.'));
console.log('');
}
if (resolved.clearEnvKeys.length > 0) {
console.log('Managed env keys replaced/cleared on write:');
console.log(` ${dim(resolved.clearEnvKeys.join(', '))}`);
console.log('');
}
if (resolvedPermissionMode) {
console.log(`Default permission mode: ${color(resolvedPermissionMode, 'command')}`);
if (resolvedPermissionMode === 'bypassPermissions') {
console.log(warn('Auto-approve enabled: Claude will skip permission prompts by default.'));
}
console.log('');
}
if (resolved.warnings?.length) {
for (const message of resolved.warnings) {
console.log(warn(message));
}
console.log('');
}
if (resolved.notes?.length) {
for (const note of resolved.notes) {
console.log(info(note));
}
console.log('');
}
// Warning about modification
console.log(warn(`This will modify ${getClaudeSettingsDisplayPath()}`));
console.log(dim(' Existing hooks and other settings will be preserved.'));
console.log(
dim(' Existing managed profile env keys will be replaced to avoid stale routing.')
);
console.log('');
// Check if settings.json exists for backup
const settingsPath = getClaudeSettingsPath();
const settingsExist = fs.existsSync(settingsPath);
let createBackupFlag = false;
// Track backup path for error recovery guidance
let createdBackupPath: string | null = null;
// Backup prompt (unless --yes)
if (settingsExist) {
createBackupFlag = parsedArgs.yes === true; // Auto-backup with --yes
if (!parsedArgs.yes) {
createBackupFlag = await InteractivePrompt.confirm('Create backup before modifying?', {
default: true,
});
}
}
// Proceed confirmation (unless --yes)
if (!parsedArgs.yes) {
const proceed = await InteractivePrompt.confirm('Proceed with persist?', { default: true });
if (!proceed) {
console.log(info('Cancelled'));
process.exit(0);
}
}
try {
await withPersistSettingsLock(async () => {
if (createBackupFlag && (await pathExists(settingsPath))) {
try {
createdBackupPath = await createBackup();
console.log(ok(`Backup created: ${formatDisplayPath(createdBackupPath)}`));
console.log('');
} catch (error) {
throw new Error(`Failed to create backup: ${(error as Error).message}`);
}
}
// Read existing settings and merge
const existingSettings = await readClaudeSettings();
// Validate existing env is an object (not array/primitive)
const rawEnv = existingSettings.env;
let existingEnv: Record<string, string> = {};
if (rawEnv !== undefined) {
if (rawEnv === null) {
console.log(warn('Existing env in settings.json is null - it will be replaced'));
} else if (typeof rawEnv !== 'object' || Array.isArray(rawEnv)) {
console.log(warn('Existing env in settings.json is not an object - it will be replaced'));
} else {
existingEnv = rawEnv as Record<string, string>;
}
}
const preservedEnv = { ...existingEnv };
for (const key of resolved.clearEnvKeys) {
delete preservedEnv[key];
}
const mergedSettings: Record<string, unknown> = {
...existingSettings,
env: {
...preservedEnv,
...resolved.env,
},
};
if (resolvedPermissionMode) {
const rawPermissions = existingSettings.permissions;
let existingPermissions: Record<string, unknown> = {};
if (rawPermissions !== undefined) {
if (rawPermissions === null) {
console.log(
warn('Existing permissions in settings.json is null - it will be replaced')
);
} else if (typeof rawPermissions !== 'object' || Array.isArray(rawPermissions)) {
console.log(
warn('Existing permissions in settings.json is not an object - it will be replaced')
);
} else {
existingPermissions = rawPermissions as Record<string, unknown>;
}
}
mergedSettings.permissions = {
...existingPermissions,
defaultMode: resolvedPermissionMode,
};
}
await writeClaudeSettings(mergedSettings);
const persistedSettings = await readClaudeSettings();
const receipt = buildPersistReceipt(
existingEnv,
existingSettings,
persistedSettings,
resolved,
resolvedPermissionMode
);
console.log('');
console.log(
ok(`Profile '${parsedArgs.profile}' written to ${getClaudeSettingsDisplayPath()}`)
);
console.log('');
printPersistReceipt(receipt);
console.log('');
});
} catch (error) {
const message = (error as Error).message;
if (message.startsWith('Failed to create backup:')) {
console.log(fail(message));
} else {
console.log(fail(`Failed to write settings: ${message}`));
}
if (createdBackupPath) {
console.log('');
console.log(info(`A backup was created before this error:`));
console.log(` ${formatDisplayPath(createdBackupPath)}`);
console.log(dim(' To restore: ccs persist --restore'));
}
process.exit(1);
}
console.log(info('Claude Code will now use this profile by default.'));
console.log(dim(' To revert, restore the backup or edit settings.json manually.'));
console.log('');
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Persist Command - Help Text
*
* Owns the `ccs persist --help` output. Pure presentation module; no
* filesystem or profile-resolution side effects.
*/
import { header, subheader, color, dim, initUI } from '../../utils/ui';
import { getClaudeSettingsDisplayPath } from './secure-file';
/** Show help for persist command */
export async function showHelp(): Promise<void> {
await initUI();
console.log(header('CCS Persist Command'));
console.log('');
console.log(subheader('Usage'));
console.log(` ${color('ccs persist', 'command')} <profile> [options]`);
console.log(` ${color('ccs persist', 'command')} --list-backups`);
console.log(` ${color('ccs persist', 'command')} --restore [timestamp]`);
console.log('');
console.log(subheader('Description'));
console.log(" Writes a profile's Claude setup directly to");
console.log(` ${getClaudeSettingsDisplayPath()} for native Claude Code usage.`);
console.log('');
console.log(' This is the preferred shared-settings path for Claude Code');
console.log(' and the Claude IDE extension when you want one profile everywhere.');
console.log('');
console.log(subheader('Options'));
console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts (auto-backup)`);
console.log(
` ${color('--permission-mode <mode>', 'command')} Set default permission mode in settings.json`
);
console.log(
` ${color('--dangerously-skip-permissions', 'command')} Persist auto-approve (bypassPermissions)`
);
console.log(` ${color('--auto-approve', 'command')} Alias for --dangerously-skip-permissions`);
console.log(` ${color('--help, -h', 'command')} Show this help message`);
console.log('');
console.log(subheader('Backup Management'));
console.log(` ${color('--list-backups', 'command')} List available backup files`);
console.log(` ${color('--restore', 'command')} Restore from the most recent backup`);
console.log(
` ${color('--restore <ts>', 'command')} Restore from specific backup (e.g., 20260110_205324)`
);
console.log('');
console.log(subheader('Supported Profile Types'));
console.log(` ${color('API profiles', 'command')} glm, km, custom API profiles`);
console.log(` ${color('CLIProxy', 'command')} gemini, agy, qwen, kiro, ghcp`);
console.log(` ${color('Copilot', 'command')} copilot (requires copilot-api daemon)`);
console.log(
` ${color('Account profiles', 'command')} work, personal, client (persists CLAUDE_CONFIG_DIR)`
);
console.log(
` ${color('default', 'command')} Clears CCS-managed overrides or inherits mapped continuity`
);
console.log('');
console.log(subheader('Examples'));
console.log(` ${dim('# Persist GLM profile')}`);
console.log(` ${color('ccs persist glm', 'command')}`);
console.log('');
console.log(` ${dim('# Persist with auto-confirmation')}`);
console.log(` ${color('ccs persist gemini --yes', 'command')}`);
console.log('');
console.log(` ${dim('# Persist with default permission mode')}`);
console.log(` ${color('ccs persist glm --permission-mode acceptEdits', 'command')}`);
console.log('');
console.log(` ${dim('# Persist with auto-approve enabled')}`);
console.log(` ${color('ccs persist glm --dangerously-skip-permissions', 'command')}`);
console.log('');
console.log(` ${dim('# Persist an account profile for IDE/native Claude use')}`);
console.log(` ${color('ccs persist work --yes', 'command')}`);
console.log('');
console.log(` ${dim('# Reset to native Claude defaults (clear CCS-managed overrides)')}`);
console.log(` ${color('ccs persist default --yes', 'command')}`);
console.log('');
console.log(` ${dim('# List all backups')}`);
console.log(` ${color('ccs persist --list-backups', 'command')}`);
console.log('');
console.log(` ${dim('# Restore latest backup')}`);
console.log(` ${color('ccs persist --restore', 'command')}`);
console.log('');
console.log(` ${dim('# Restore specific backup')}`);
console.log(` ${color('ccs persist --restore 20260110_205324', 'command')}`);
console.log('');
console.log(subheader('Notes'));
console.log(' [i] CLIProxy profiles require the proxy to be running.');
console.log(
' [i] Codex CLIProxy profiles are native Codex-only: use ccsxp or ccs codex --target codex.'
);
console.log(' [i] Copilot profiles require copilot-api daemon.');
console.log(
' [i] Account/default flows remove stale ANTHROPIC_* overrides before applying new setup.'
);
console.log(
' [i] For IDE-local settings.json snippets, use: ccs env <profile> --format claude-extension'
);
console.log(
` [i] Backups are saved as ${getClaudeSettingsDisplayPath()}.backup.YYYYMMDD_HHMMSS`
);
console.log('');
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Persist Command - Receipt Building & Profile Resolution
*
* Computes the post-write receipt (cleared/written/unchanged env keys and
* settings) and resolves a profile's extension env vars via the shared
* Claude extension setup resolver.
*/
import { resolveClaudeExtensionSetup } from '../../shared/claude-extension-setup';
import {
CODEX_TRANSLATOR_URL_MARKER,
findCodexTranslatorUrlPaths,
formatSettingsPathList,
} from '../../shared/stale-codex-translator-settings';
import { subheader, ok, warn } from '../../utils/ui';
import { getClaudeSettingsDisplayPath } from './secure-file';
import {
NATIVE_CODEX_TARGETS,
type PersistReceipt,
type PermissionMode,
type ResolvedEnv,
} from './types';
export function buildPersistReceipt(
existingEnv: Record<string, string>,
existingSettings: Record<string, unknown>,
mergedSettings: Record<string, unknown>,
resolved: ResolvedEnv,
resolvedPermissionMode?: PermissionMode
): PersistReceipt {
const mergedEnv =
typeof mergedSettings.env === 'object' &&
mergedSettings.env !== null &&
!Array.isArray(mergedSettings.env)
? (mergedSettings.env as Record<string, string>)
: {};
const clearedKeys = resolved.clearEnvKeys.filter(
(key) => Object.prototype.hasOwnProperty.call(existingEnv, key) && mergedEnv[key] === undefined
);
const clearedCodexTranslatorUrlKeys = clearedKeys.filter(
(key) => findCodexTranslatorUrlPaths(existingEnv[key]).length > 0
);
const writtenKeys = Object.entries(resolved.env)
.filter(([key, value]) => existingEnv[key] !== value)
.map(([key]) => key)
.sort((left, right) => left.localeCompare(right));
const unchangedWrittenKeys = Object.entries(resolved.env)
.filter(([key, value]) => existingEnv[key] === value)
.map(([key]) => key)
.sort((left, right) => left.localeCompare(right));
const existingPermissions =
typeof existingSettings.permissions === 'object' &&
existingSettings.permissions !== null &&
!Array.isArray(existingSettings.permissions)
? (existingSettings.permissions as Record<string, unknown>)
: {};
const writtenSettings =
resolvedPermissionMode && existingPermissions.defaultMode !== resolvedPermissionMode
? ['permissions.defaultMode']
: [];
const unchangedSettings =
resolvedPermissionMode && existingPermissions.defaultMode === resolvedPermissionMode
? ['permissions.defaultMode']
: [];
return {
clearedKeys,
clearedCodexTranslatorUrlKeys,
writtenKeys,
unchangedWrittenKeys,
writtenSettings,
unchangedSettings,
codexTranslatorUrlPaths: findCodexTranslatorUrlPaths(mergedSettings),
};
}
function formatKeyList(keys: string[]): string {
return keys.length > 0 ? keys.join(', ') : 'none';
}
export function printPersistReceipt(receipt: PersistReceipt): void {
console.log(subheader('Config Receipt'));
console.log(` Settings: ${getClaudeSettingsDisplayPath()}`);
console.log(` Cleared managed keys: ${formatKeyList(receipt.clearedKeys)}`);
console.log(` Written/rewritten managed keys: ${formatKeyList(receipt.writtenKeys)}`);
if (receipt.unchangedWrittenKeys.length > 0) {
console.log(` Already current keys: ${formatKeyList(receipt.unchangedWrittenKeys)}`);
}
if (receipt.writtenSettings.length > 0 || receipt.unchangedSettings.length > 0) {
console.log(` Written/rewritten managed settings: ${formatKeyList(receipt.writtenSettings)}`);
if (receipt.unchangedSettings.length > 0) {
console.log(` Already current settings: ${formatKeyList(receipt.unchangedSettings)}`);
}
}
const hadCodexTranslatorCleanup = receipt.clearedCodexTranslatorUrlKeys.length > 0;
if (receipt.codexTranslatorUrlPaths.length > 0) {
console.log(
warn(
` Codex translator URL: still found at ${formatSettingsPathList(
receipt.codexTranslatorUrlPaths
)} (${CODEX_TRANSLATOR_URL_MARKER})`
)
);
} else {
console.log(ok(' Codex translator URL: not found'));
}
if (hadCodexTranslatorCleanup || receipt.codexTranslatorUrlPaths.length > 0) {
console.log(` Native Codex target: ${NATIVE_CODEX_TARGETS.join(' or ')}`);
}
}
/** Resolve shared Claude settings payload for a profile */
export async function resolveProfileEnvVars(profileName: string): Promise<ResolvedEnv> {
const setup = await resolveClaudeExtensionSetup(profileName);
const typeLabel: Record<string, string> = {
settings: 'API',
cliproxy: 'CLIProxy',
copilot: 'Copilot',
account: 'Account',
default: 'Default',
};
return {
env: setup.extensionEnv,
clearEnvKeys: setup.removeEnvKeys,
profileType: typeLabel[setup.profileType] ?? setup.profileType,
warnings: setup.warnings,
notes: setup.notes,
};
}
@@ -0,0 +1,54 @@
/**
* Persist Command - Secret Detection & Masking
*
* Identifies sensitive env var names (TOKEN/KEY/SECRET/etc.) so the persist
* preview can mask their values before printing to the terminal.
*/
/** Mask API key for display (show first 4 and last 4 chars) */
export function maskApiKey(key: string): string {
if (key.length <= 12) {
return '****';
}
return `${key.slice(0, 4)}...${key.slice(-4)}`;
}
const SENSITIVE_ENV_PARTS = new Set([
'TOKEN',
'KEY',
'SECRET',
'PASSWORD',
'PASS',
'AUTH',
'CREDENTIAL',
'PRIVATE',
'ACCESS',
'REFRESH',
'APIKEY',
]);
export function splitSensitiveKeyParts(key: string): string[] {
const withCamelCaseBoundaries = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2');
return withCamelCaseBoundaries
.toUpperCase()
.split(/[^A-Z0-9]+/)
.filter(Boolean);
}
export function isSensitiveEnvKey(key: string): boolean {
const parts = splitSensitiveKeyParts(key);
if (parts.some((part) => SENSITIVE_ENV_PARTS.has(part))) {
return true;
}
const compact = parts.join('');
return (
compact.includes('TOKEN') ||
compact.includes('APIKEY') ||
compact.includes('ACCESSKEY') ||
compact.includes('AUTHKEY') ||
compact.includes('SECRET') ||
compact.includes('PASSWORD') ||
compact.includes('CREDENTIAL')
);
}
+220
View File
@@ -0,0 +1,220 @@
/**
* Persist Command - Secure File I/O & Locking
*
* Hardened filesystem helpers for reading/writing ~/.claude/settings.json.
* Refuses to follow symlinks (TOCTOU mitigations), uses O_NOFOLLOW where
* available, writes via atomic temp-file + rename, and serializes concurrent
* persist operations via a proper-lockfile on the settings directory.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as lockfile from 'proper-lockfile';
import { getClaudeConfigDir, getClaudeSettingsPath } from '../../utils/claude-config-path';
import {
PERSIST_LOCK_RETRIES,
PERSIST_LOCK_RETRY_MAX_MS,
PERSIST_LOCK_RETRY_MIN_MS,
PERSIST_LOCK_STALE_MS,
} from './types';
export function formatDisplayPath(filePath: string): string {
const defaultClaudeDir = path.join(os.homedir(), '.claude');
const claudeDir = getClaudeConfigDir();
// Keep real path when user overrides Claude directory.
if (path.resolve(claudeDir) !== path.resolve(defaultClaudeDir)) {
return filePath;
}
if (filePath === claudeDir) {
return '~/.claude';
}
const claudePrefix = `${claudeDir}${path.sep}`;
if (filePath.startsWith(claudePrefix)) {
return filePath.replace(claudePrefix, '~/.claude/');
}
return filePath;
}
export function getClaudeSettingsDisplayPath(): string {
return formatDisplayPath(getClaudeSettingsPath());
}
export async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.promises.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
export async function isSymlinkAsync(filePath: string): Promise<boolean> {
try {
const stats = await fs.promises.lstat(filePath);
return stats.isSymbolicLink();
} catch {
return false;
}
}
export function getNoFollowFlag(): number {
const candidate = (fs.constants as Record<string, number>)['O_NOFOLLOW'];
if (process.platform !== 'win32' && typeof candidate === 'number') {
return candidate;
}
return 0;
}
function createSymlinkReadError(filePath: string): NodeJS.ErrnoException {
const error = new Error(
`Refusing to read symlinked file for security: ${formatDisplayPath(filePath)}`
) as NodeJS.ErrnoException;
error.code = 'ELOOP';
return error;
}
export async function readFileUtf8NoFollow(filePath: string): Promise<string> {
if (await isSymlinkAsync(filePath)) {
throw createSymlinkReadError(filePath);
}
const noFollowFlag = getNoFollowFlag();
const flags = fs.constants.O_RDONLY | noFollowFlag;
const handle = await fs.promises.open(filePath, flags);
try {
// Best-effort fallback for platforms without O_NOFOLLOW (notably Windows).
// Re-check symlink status after open to reduce check-then-use windows.
if (noFollowFlag === 0 && (await isSymlinkAsync(filePath))) {
throw createSymlinkReadError(filePath);
}
const stats = await handle.stat();
if (!stats.isFile()) {
throw new Error('Path is not a regular file');
}
if (noFollowFlag === 0) {
const latestStats = await fs.promises.stat(filePath);
if (latestStats.dev !== stats.dev || latestStats.ino !== stats.ino) {
throw new Error('Path changed during secure read');
}
}
return await handle.readFile({ encoding: 'utf8' });
} finally {
await handle.close();
}
}
export function parseSettingsObject(content: string, sourceLabel: string): Record<string, unknown> {
if (!content.trim()) {
return {};
}
const parsed: unknown = JSON.parse(content);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`${sourceLabel} must contain a JSON object, not an array or primitive`);
}
return parsed as Record<string, unknown>;
}
export async function withPersistSettingsLock<T>(operation: () => Promise<T>): Promise<T> {
const settingsPath = getClaudeSettingsPath();
const settingsDir = path.dirname(settingsPath);
await fs.promises.mkdir(settingsDir, { recursive: true });
let release: (() => Promise<void>) | undefined;
try {
release = await lockfile.lock(settingsDir, {
stale: PERSIST_LOCK_STALE_MS,
retries: {
retries: PERSIST_LOCK_RETRIES,
minTimeout: PERSIST_LOCK_RETRY_MIN_MS,
maxTimeout: PERSIST_LOCK_RETRY_MAX_MS,
},
realpath: false,
});
} catch (error) {
throw new Error(
`Failed to lock Claude settings directory (${formatDisplayPath(settingsDir)}): ${(error as Error).message}`
);
}
try {
return await operation();
} finally {
if (release) {
try {
await release();
} catch {
// Best-effort release.
}
}
}
}
/** Read existing Claude settings.json with validation */
export async function readClaudeSettings(): Promise<Record<string, unknown>> {
const settingsPath = getClaudeSettingsPath();
try {
const content = await readFileUtf8NoFollow(settingsPath);
return parseSettingsObject(content, 'settings.json');
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === 'ENOENT') {
return {};
}
if (nodeError.code === 'ELOOP') {
throw new Error('settings.json is a symlink - refusing to read for security');
}
throw new Error(`Failed to parse settings.json: ${(error as Error).message}`);
}
}
/** Write settings back to settings.json with atomic replace semantics. */
export async function writeClaudeSettings(settings: Record<string, unknown>): Promise<void> {
const settingsPath = getClaudeSettingsPath();
if (await isSymlinkAsync(settingsPath)) {
throw new Error('settings.json is a symlink - refusing to write for security');
}
const settingsDir = path.dirname(settingsPath);
await fs.promises.mkdir(settingsDir, { recursive: true });
const nonce = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const tmpPath = path.join(settingsDir, `settings.json.tmp-${nonce}`);
const flags =
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | getNoFollowFlag();
let handle: fs.promises.FileHandle | undefined;
try {
handle = await fs.promises.open(tmpPath, flags, 0o600);
await handle.writeFile(JSON.stringify(settings, null, 2) + '\n', { encoding: 'utf8' });
await handle.sync();
} finally {
if (handle) {
await handle.close();
}
}
try {
await fs.promises.rename(tmpPath, settingsPath);
} catch (error) {
try {
await fs.promises.unlink(tmpPath);
} catch {
// Best-effort cleanup.
}
throw error;
}
try {
await fs.promises.chmod(settingsPath, 0o600);
} catch {
// Best-effort permission hardening.
}
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Persist Command - Shared Types & Constants
*
* Shared interfaces, type aliases, and module constants used across the
* persist-command submodules. Keeping these in one place avoids circular
* imports between arg-parsing, receipt, secure-file, and handler modules.
*/
export interface PersistCommandArgs {
profile?: string;
yes?: boolean;
listBackups?: boolean;
restore?: string | boolean;
permissionMode?: PermissionMode;
dangerouslySkipPermissions?: boolean;
parseError?: string;
}
export interface ResolvedEnv {
env: Record<string, string>;
clearEnvKeys: string[];
profileType: string;
warnings?: string[];
notes?: string[];
}
export interface PersistReceipt {
clearedKeys: string[];
clearedCodexTranslatorUrlKeys: string[];
writtenKeys: string[];
unchangedWrittenKeys: string[];
writtenSettings: string[];
unchangedSettings: string[];
codexTranslatorUrlPaths: string[];
}
export const PERSIST_KNOWN_FLAGS = [
'--yes',
'-y',
'--list-backups',
'--restore',
'--permission-mode',
'--dangerously-skip-permissions',
'--auto-approve',
'--help',
'-h',
] as const;
export const VALID_PERMISSION_MODES = [
'default',
'plan',
'acceptEdits',
'bypassPermissions',
] as const;
export const PERSIST_LOCK_STALE_MS = 10000;
export const PERSIST_LOCK_RETRIES = 5;
export const PERSIST_LOCK_RETRY_MIN_MS = 100;
export const PERSIST_LOCK_RETRY_MAX_MS = 500;
/** Native Codex target invocation hints surfaced in the persist receipt. */
export const NATIVE_CODEX_TARGETS = ['ccsxp', 'ccs codex --target codex'];
export type PermissionMode = (typeof VALID_PERMISSION_MODES)[number];
File diff suppressed because it is too large. Load diff
+222
View File
@@ -0,0 +1,222 @@
/**
* SharedManager - low-level filesystem helpers.
*
* Extracted from the original monolithic shared-manager.ts. All functions
* here are pure with respect to SharedManager instance state: they take
* explicit paths and return values, never reading from `this`.
*
* Keeping these isolated lets the orchestrator class focus on coordination
* and makes each helper independently testable.
*/
import * as fs from 'fs';
import * as path from 'path';
import type { SharedItem } from './types';
/**
* Return canonical realpath for a path. Falls back to the lexical resolve
* when realpath fails (e.g. path does not exist).
*/
export function resolveCanonicalPath(targetPath: string): string {
try {
return fs.realpathSync.native(targetPath);
} catch {
return path.resolve(targetPath);
}
}
/**
* Case-insensitive on Windows, case-sensitive elsewhere. Returns true when
* candidatePath === rootPath or candidatePath is a descendant of rootPath.
*/
export function isPathWithinDirectory(candidatePath: string, rootPath: string): boolean {
const normalizeForCompare = (inputPath: string): string => {
const resolved = path.resolve(inputPath);
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
};
const normalizedCandidate = normalizeForCompare(candidatePath);
const normalizedRoot = normalizeForCompare(rootPath);
const relative = path.relative(normalizedRoot, normalizedCandidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
/**
* Resolve a symlink to its absolute target and compare against an expected
* target. Synchronous variant used during detach/remove flows.
*/
export function symlinkPointsTo(linkPath: string, expectedTarget: string): boolean {
try {
const currentTarget = fs.readlinkSync(linkPath);
const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget);
return resolveCanonicalPath(resolvedCurrentTarget) === resolveCanonicalPath(expectedTarget);
} catch {
return false;
}
}
/**
* Remove an existing path, using the type hint to disambiguate between
* a file and a directory when lstat fails to give a clear signal.
*/
export function removeExistingPath(targetPath: string, typeHint: SharedItem['type']): void {
try {
const stats = fs.lstatSync(targetPath);
if (stats.isDirectory() && !stats.isSymbolicLink()) {
fs.rmSync(targetPath, { recursive: true, force: true });
return;
}
if (stats.isSymbolicLink() || typeHint === 'file') {
fs.unlinkSync(targetPath);
return;
}
fs.rmSync(targetPath, { recursive: true, force: true });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return;
}
if (typeHint === 'directory') {
fs.rmSync(targetPath, { recursive: true, force: true });
} else {
fs.rmSync(targetPath, { force: true });
}
}
}
/**
* Recursively copy a directory tree as a fallback when symlinks are not
* available (notably Windows without Developer Mode). Creates the source
* directory as an empty dir if it does not exist.
*/
export function copyDirectoryFallback(src: string, dest: string): void {
if (!fs.existsSync(src)) {
fs.mkdirSync(src, { recursive: true, mode: 0o700 });
return;
}
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirectoryFallback(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/**
* Move a directory, falling back to recursive copy+remove across devices.
*/
export async function moveDirectory(src: string, dest: string): Promise<void> {
try {
await fs.promises.rename(src, dest);
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code !== 'EXDEV') {
throw err;
}
await fs.promises.cp(src, dest, { recursive: true });
await fs.promises.rm(src, { recursive: true, force: true });
}
}
/**
* Promise-based access() existence check.
*/
export async function pathExists(targetPath: string): Promise<boolean> {
try {
await fs.promises.access(targetPath);
return true;
} catch (_err) {
return false;
}
}
/**
* Ensure a directory exists with restrictive permissions.
*/
export async function ensureDirectory(targetPath: string): Promise<void> {
await fs.promises.mkdir(targetPath, { recursive: true, mode: 0o700 });
}
/**
* Promise-based lstat, returning null for ENOENT.
*/
export async function getLstat(targetPath: string): Promise<fs.Stats | null> {
try {
return await fs.promises.lstat(targetPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
throw err;
}
}
/**
* Sync lstat, returning null for ENOENT.
*/
export function getLstatSync(targetPath: string): fs.Stats | null {
try {
return fs.lstatSync(targetPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
throw err;
}
}
/**
* Compare two files byte-for-byte. Returns false on any IO error.
*/
export async function fileContentsEqual(fileA: string, fileB: string): Promise<boolean> {
try {
const [statA, statB] = await Promise.all([fs.promises.stat(fileA), fs.promises.stat(fileB)]);
if (statA.size !== statB.size) {
return false;
}
const [contentA, contentB] = await Promise.all([
fs.promises.readFile(fileA),
fs.promises.readFile(fileB),
]);
return contentA.equals(contentB);
} catch (_err) {
return false;
}
}
/**
* Build a non-destructive conflict copy path of the form
* "<target>.migrated-from-<instance>[-N]".
*/
export async function getConflictCopyPath(
existingTargetPath: string,
instanceName: string
): Promise<string> {
const safeInstanceName = instanceName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
const baseSuffix = `.migrated-from-${safeInstanceName}`;
let candidate = `${existingTargetPath}${baseSuffix}`;
let sequence = 1;
while (await pathExists(candidate)) {
candidate = `${existingTargetPath}${baseSuffix}-${sequence}`;
sequence++;
}
return candidate;
}
+183
View File
@@ -0,0 +1,183 @@
/**
* SharedManager - one-shot upgrade migrations.
*
* Extracted from the original monolithic shared-manager.ts. These run once
* on upgrade to reconcile older on-disk layouts into the current symlink
* based architecture.
*/
import * as fs from 'fs';
import * as path from 'path';
import { info, ok, warn } from '../../utils/ui';
import { listAccountInstanceNames } from '../instance-directory';
import {
ensureSharedDirectories,
linkSharedDirectories,
type LinkerRoots,
} from './shared-dir-linker';
import { SHARED_ITEMS } from './types';
/**
* Migrate from v3.1.1 (copied data in ~/.ccs/shared/) to v3.2.0 (symlinks
* to ~/.claude/). Runs once on upgrade; exits early when the shared
* commands directory is already a symlink.
*/
export function migrateFromV311(roots: LinkerRoots): void {
const sharedDir = roots.sharedDir;
const claudeDir = roots.claudeDir;
const commandsPath = path.join(sharedDir, 'commands');
if (fs.existsSync(commandsPath)) {
try {
if (fs.lstatSync(commandsPath).isSymbolicLink()) {
return;
}
} catch (_err) {
// Continue with migration
}
}
console.log(info('Migrating from v3.1.1 to v3.2.0...'));
if (!fs.existsSync(claudeDir)) {
fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 });
}
for (const item of SHARED_ITEMS) {
const sharedPath = path.join(sharedDir, item.name);
const claudePath = path.join(claudeDir, item.name);
if (!fs.existsSync(sharedPath)) continue;
try {
const stats = fs.lstatSync(sharedPath);
if (item.type === 'directory' && stats.isDirectory()) {
if (!fs.existsSync(claudePath)) {
fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 });
}
const entries = fs.readdirSync(sharedPath, { withFileTypes: true });
let copied = 0;
for (const entry of entries) {
const src = path.join(sharedPath, entry.name);
const dest = path.join(claudePath, entry.name);
if (fs.existsSync(dest)) continue;
if (entry.isDirectory()) {
fs.cpSync(src, dest, { recursive: true });
} else {
fs.copyFileSync(src, dest);
}
copied++;
}
if (copied > 0) {
console.log(ok(`Migrated ${copied} ${item.name} to ~/.claude/${item.name}`));
}
} else if (item.type === 'file' && stats.isFile()) {
if (!fs.existsSync(claudePath)) {
fs.copyFileSync(sharedPath, claudePath);
console.log(ok(`Migrated ${item.name} to ~/.claude/${item.name}`));
}
}
} catch (_err) {
console.log(warn(`Failed to migrate ${item.name}: ${(_err as Error).message}`));
}
}
ensureSharedDirectories(roots);
if (fs.existsSync(roots.instancesDir)) {
try {
for (const instance of listAccountInstanceNames(roots.instancesDir)) {
const instancePath = path.join(roots.instancesDir, instance);
try {
linkSharedDirectories(roots, instancePath);
} catch (_err) {
console.log(warn(`Failed to update instance ${instance}: ${(_err as Error).message}`));
}
}
} catch (_err) {
// No instances to update
}
}
console.log(ok('Migration to v3.2.0 complete'));
}
/**
* Migrate existing instances from isolated to shared settings.json (v4.4+).
* Backs up each instance's pre-existing settings.json before replacing it
* with a symlink to the shared settings.json.
*/
export function migrateToSharedSettings(roots: LinkerRoots): void {
const claudeDir = roots.claudeDir;
const sharedDir = roots.sharedDir;
const instancesDir = roots.instancesDir;
console.log(info('Migrating instances to shared settings.json...'));
const claudeSettings = path.join(claudeDir, 'settings.json');
if (!fs.existsSync(claudeSettings)) {
fs.writeFileSync(claudeSettings, JSON.stringify({}, null, 2), 'utf8');
console.log(info('Created ~/.claude/settings.json'));
}
ensureSharedDirectories(roots);
if (!fs.existsSync(instancesDir)) {
console.log(info('No instances to migrate'));
return;
}
const instances = listAccountInstanceNames(instancesDir);
let migrated = 0;
let skipped = 0;
for (const instance of instances) {
const instancePath = path.join(instancesDir, instance);
const instanceSettings = path.join(instancePath, 'settings.json');
try {
if (fs.existsSync(instanceSettings)) {
const stats = fs.lstatSync(instanceSettings);
if (stats.isSymbolicLink()) {
skipped++;
continue;
}
const backup = instanceSettings + '.pre-shared-migration';
if (!fs.existsSync(backup)) {
fs.copyFileSync(instanceSettings, backup);
console.log(info(`Backed up ${instance}/settings.json`));
}
fs.unlinkSync(instanceSettings);
}
const sharedSettings = path.join(sharedDir, 'settings.json');
try {
fs.symlinkSync(sharedSettings, instanceSettings, 'file');
migrated++;
} catch (_err) {
if (process.platform === 'win32') {
fs.copyFileSync(sharedSettings, instanceSettings);
console.log(warn(`Symlink failed for ${instance}, copied instead`));
migrated++;
} else {
throw _err;
}
}
} catch (_err) {
console.log(warn(`Failed to migrate ${instance}: ${(_err as Error).message}`));
}
}
console.log(ok(`Migrated ${migrated} instance(s), skipped ${skipped}`));
}
@@ -0,0 +1,175 @@
/**
* SharedManager orchestrator.
*
* Thin coordinator over the focused submodules under shared-manager/. The
* class is responsible ONLY for:
* 1. Resolving filesystem roots (homeDir, ccsDir, sharedDir, claudeDir,
* instancesDir) and the plugin-layout lock.
* 2. Forwarding every public method to the appropriate extracted helper,
* passing those roots explicitly.
*
* All behavior, signatures, and structured logging live in the submodules
* and are preserved exactly. This file deliberately contains no logic of
* its own.
*/
import * as os from 'os';
import * as path from 'path';
import { warn } from '../../utils/ui';
import type { AccountContextPolicy } from '../../auth/account-context';
import { getCcsDir } from '../../config/config-loader-facade';
import ProfileContextSyncLock from '../profile-context-sync-lock';
import {
normalizeMarketplaceRegistryPaths,
normalizePluginRegistryPaths,
type PluginMetadataRoots,
} from './plugin-metadata-normalizer';
import {
ensureSharedDirectories,
detachSharedDirectories,
linkSharedDirectories,
type LinkerRoots,
} from './shared-dir-linker';
import {
syncAdvancedContinuityArtifacts,
syncProjectContext,
type ContextSyncRoots,
} from './project-context-sync';
import { syncProjectMemories } from './project-memory-sync';
import { migrateFromV311, migrateToSharedSettings } from './migrations';
import type { SymlinkHelperDeps } from './symlink-helpers';
/**
* SharedManager Class
*
* Manages symlinked shared directories for CCS. See submodule docs for the
* detailed behavior of each operation.
*/
class SharedManager {
private readonly homeDir: string;
private readonly sharedDir: string;
private readonly claudeDir: string;
private readonly instancesDir: string;
private readonly pluginLayoutLock: ProfileContextSyncLock;
constructor() {
this.homeDir = os.homedir();
const ccsDir = getCcsDir();
this.sharedDir = path.join(ccsDir, 'shared');
this.claudeDir = path.join(this.homeDir, '.claude');
this.instancesDir = path.join(ccsDir, 'instances');
this.pluginLayoutLock = new ProfileContextSyncLock(this.instancesDir);
}
private get roots(): LinkerRoots & PluginMetadataRoots & ContextSyncRoots {
return {
claudeDir: this.claudeDir,
sharedDir: this.sharedDir,
instancesDir: this.instancesDir,
};
}
private get symlinkDeps(): SymlinkHelperDeps {
return { warn };
}
/**
* Ensure shared directories exist as symlinks to ~/.claude/. Creates
* ~/.claude/ structure if missing.
*/
ensureSharedDirectories(): void {
ensureSharedDirectories(this.roots);
}
/**
* Link shared directories into an instance.
*/
linkSharedDirectories(instancePath: string): void {
linkSharedDirectories(this.roots, instancePath);
}
/**
* Detach shared-directory symlinks from an instance.
*/
detachSharedDirectories(instancePath: string): void {
detachSharedDirectories(this.roots, instancePath);
}
/**
* Sync project workspace context based on account policy.
*/
async syncProjectContext(instancePath: string, policy: AccountContextPolicy): Promise<void> {
await syncProjectContext(this.roots, instancePath, policy, this.symlinkDeps);
}
/**
* Sync advanced continuity artifacts for shared deeper mode.
*/
async syncAdvancedContinuityArtifacts(
instancePath: string,
policy: AccountContextPolicy
): Promise<void> {
await syncAdvancedContinuityArtifacts(this.roots, instancePath, policy, this.symlinkDeps);
}
/**
* Ensure all project memory directories for an instance are shared.
*/
async syncProjectMemories(instancePath: string): Promise<void> {
await syncProjectMemories(this.roots, instancePath, this.symlinkDeps);
}
/**
* Normalize plugin metadata and reconcile marketplace metadata for the
* active config dir.
*/
normalizeSharedPluginMetadataPaths(configDir?: string): void {
this.normalizePluginRegistryPaths(configDir);
this.normalizeMarketplaceRegistryPaths(configDir);
}
/**
* Same as normalizeSharedPluginMetadataPaths but guarded by the
* plugin-layout named lock so concurrent instances cannot interleave.
*/
normalizeSharedPluginMetadataPathsLocked(configDir?: string): void {
this.pluginLayoutLock.withNamedLockSync('__plugin-layout__', () => {
this.normalizeSharedPluginMetadataPaths(configDir);
});
}
/**
* Normalize installed_plugins.json paths to canonical ~/.claude/ paths.
*/
normalizePluginRegistryPaths(configDir?: string): void {
normalizePluginRegistryPaths(this.roots, configDir);
}
/**
* Reconcile marketplace registry content into the active config dir and
* keep the global ~/.claude copy up to date.
*/
normalizeMarketplaceRegistryPaths(configDir?: string): void {
normalizeMarketplaceRegistryPaths(this.roots, configDir);
}
/**
* Migrate from v3.1.1 (copied data in ~/.ccs/shared/) to v3.2.0 (symlinks
* to ~/.claude/). Runs once on upgrade.
*/
migrateFromV311(): void {
migrateFromV311(this.roots);
}
/**
* Migrate existing instances from isolated to shared settings.json
* (v4.4+). Runs once on upgrade.
*/
migrateToSharedSettings(): void {
migrateToSharedSettings(this.roots);
}
}
export default SharedManager;
@@ -0,0 +1,205 @@
/**
* SharedManager - plugin layout linking internals.
*
* Extracted from shared-dir-linker.ts to keep each file focused and under
* the 400 LOC target. Owns the four plugin-layout primitives:
*
* - ensureSharedPluginLayoutDefaults: provision ~/.claude/plugins defaults
* - linkInstancePlugins: link shared plugin entries into an instance
* - getSharedPluginLinkItems: enumerate plugin entries to link
* - detachManagedPluginLayout: reverse link + reconcile local registry
*/
import * as fs from 'fs';
import * as path from 'path';
import { warn } from '../../utils/ui';
import { copyDirectoryFallback, removeExistingPath, symlinkPointsTo } from './fs-helpers';
import type { PluginMetadataRoots } from './plugin-metadata-normalizer';
import { reconcileLocalMarketplaceRegistry } from './plugin-metadata-normalizer';
import {
DEFAULT_INSTALLED_PLUGIN_REGISTRY,
INSTANCE_LOCAL_PLUGIN_METADATA_FILES,
SHARED_PLUGIN_ENTRIES,
} from './types';
import type { SharedItem } from './types';
/**
* Roots for the plugin-layout internals. Same shape as PluginMetadataRoots.
*/
export type PluginLayoutRoots = PluginMetadataRoots;
/**
* Ensure the plugin layout default directories (cache, marketplaces) and
* registry files (installed_plugins.json, known_marketplaces.json) exist
* under ~/.claude/plugins.
*/
export function ensureSharedPluginLayoutDefaults(claudeDir: string): void {
const pluginsDir = path.join(claudeDir, 'plugins');
fs.mkdirSync(pluginsDir, { recursive: true, mode: 0o700 });
for (const entry of SHARED_PLUGIN_ENTRIES) {
const entryPath = path.join(pluginsDir, entry.name);
if (fs.existsSync(entryPath)) {
continue;
}
if (entry.type === 'directory') {
fs.mkdirSync(entryPath, { recursive: true, mode: 0o700 });
continue;
}
fs.writeFileSync(entryPath, DEFAULT_INSTALLED_PLUGIN_REGISTRY, 'utf8');
}
const marketplaceRegistryPath = path.join(pluginsDir, 'known_marketplaces.json');
if (!fs.existsSync(marketplaceRegistryPath)) {
fs.writeFileSync(marketplaceRegistryPath, JSON.stringify({}, null, 2), 'utf8');
}
}
/**
* Link shared plugins directory entries into an instance, creating the
* instance plugins directory first if needed.
*/
export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: string): void {
const linkPath = path.join(instancePath, 'plugins');
const targetPath = path.join(roots.sharedDir, 'plugins');
let linkStats: fs.Stats | null = null;
try {
linkStats = fs.lstatSync(linkPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err;
}
}
if (linkStats?.isSymbolicLink() || (linkStats && !linkStats.isDirectory())) {
removeExistingPath(linkPath, linkStats.isDirectory() ? 'directory' : 'file');
}
if (!linkStats || !linkStats.isDirectory()) {
fs.mkdirSync(linkPath, { recursive: true, mode: 0o700 });
}
for (const item of getSharedPluginLinkItems(roots.sharedDir)) {
const targetEntryPath = path.join(targetPath, item.name);
const linkEntryPath = path.join(linkPath, item.name);
removeExistingPath(linkEntryPath, item.type);
try {
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
fs.symlinkSync(targetEntryPath, linkEntryPath, symlinkType);
} catch (_err) {
if (process.platform === 'win32') {
if (item.type === 'directory') {
copyDirectoryFallback(targetEntryPath, linkEntryPath);
} else {
fs.copyFileSync(targetEntryPath, linkEntryPath);
}
console.log(
warn(`Symlink failed for plugins/${item.name}, copied instead (enable Developer Mode)`)
);
} else {
throw _err;
}
}
}
}
/**
* Build the list of plugin entries to link from the shared plugins dir.
* Always includes the default SHARED_PLUGIN_ENTRIES; adds any additional
* entries physically present on disk, skipping instance-local metadata.
*/
export function getSharedPluginLinkItems(sharedDir: string): SharedItem[] {
const sharedPluginsPath = path.join(sharedDir, 'plugins');
const items = new Map<string, SharedItem>(
SHARED_PLUGIN_ENTRIES.map((entry) => [entry.name, { ...entry }])
);
for (const entry of fs.readdirSync(sharedPluginsPath, { withFileTypes: true })) {
if (items.has(entry.name) || INSTANCE_LOCAL_PLUGIN_METADATA_FILES.has(entry.name)) {
continue;
}
const entryPath = path.join(sharedPluginsPath, entry.name);
let stats: fs.Stats;
try {
stats = fs.statSync(entryPath);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
console.log(
warn(
`Skipping plugins/${entry.name}: unable to inspect shared plugin entry${code ? ` (${code})` : ''}`
)
);
continue;
}
items.set(entry.name, {
name: entry.name,
type: stats.isDirectory() ? 'directory' : 'file',
});
}
return [...items.values()];
}
/**
* Detach managed plugin layout from an instance. Removes the symlinked
* plugin entries that point back at the shared plugins dir and reconciles
* the local marketplace registry. Removes the plugins directory entirely
* if it ends up empty.
*/
export function detachManagedPluginLayout(roots: PluginLayoutRoots, instancePath: string): void {
const pluginsPath = path.join(instancePath, 'plugins');
if (!fs.existsSync(pluginsPath)) {
return;
}
const stats = fs.lstatSync(pluginsPath);
const sharedPluginsPath = path.join(roots.sharedDir, 'plugins');
if (stats.isSymbolicLink()) {
if (symlinkPointsTo(pluginsPath, sharedPluginsPath)) {
removeExistingPath(pluginsPath, 'directory');
}
return;
}
if (!stats.isDirectory()) {
return;
}
let removedManagedEntries = false;
for (const item of getSharedPluginLinkItems(roots.sharedDir)) {
const pluginEntryPath = path.join(pluginsPath, item.name);
if (!fs.existsSync(pluginEntryPath)) {
continue;
}
const entryStats = fs.lstatSync(pluginEntryPath);
if (!entryStats.isSymbolicLink()) {
continue;
}
if (symlinkPointsTo(pluginEntryPath, path.join(sharedPluginsPath, item.name))) {
removeExistingPath(pluginEntryPath, item.type);
removedManagedEntries = true;
}
}
if (!removedManagedEntries) {
return;
}
reconcileLocalMarketplaceRegistry(roots, instancePath);
if (fs.readdirSync(pluginsPath).length === 0) {
fs.rmSync(pluginsPath, { recursive: true, force: true });
}
}
@@ -0,0 +1,331 @@
/**
* SharedManager - plugin metadata normalization and marketplace reconciliation.
*
* Extracted from the original monolithic shared-manager.ts. These helpers
* normalize installed_plugins.json and known_marketplaces.json contents so
* plugin metadata refers to canonical ~/.claude/ paths rather than
* instance-specific paths, and reconcile the marketplace registry against
* the on-disk marketplace directories.
*
* All filesystem roots (claudeDir, sharedDir, instancesDir) are passed
* explicitly to keep this module stateless and testable in isolation.
*/
import * as fs from 'fs';
import * as path from 'path';
import { ok, warn } from '../../utils/ui';
import {
normalizePluginMetadataContent,
normalizePluginMetadataValue,
} from '../plugin-path-normalizer';
import { listAccountInstancePaths } from '../instance-directory';
import { removeExistingPath, resolveCanonicalPath } from './fs-helpers';
import type { SharedItem } from './types';
/**
* Roots that plugin-metadata normalization operates on. The SharedManager
* orchestrator supplies its own private fields when constructing this
* object.
*/
export interface PluginMetadataRoots {
claudeDir: string;
sharedDir: string;
instancesDir: string;
}
/**
* Normalize every installed_plugins.json file reachable from the given roots
* so its embedded paths are canonical ~/.claude/ paths.
*/
export function normalizePluginRegistryPaths(roots: PluginMetadataRoots, configDir?: string): void {
normalizePluginMetadataFiles(
roots,
'installed_plugins.json',
configDir,
'Normalized plugin registry paths',
'plugin registry'
);
}
/**
* Reconcile marketplace registry content into the active config dir while
* keeping the global ~/.claude copy up to date for non-instance flows.
*/
export function normalizeMarketplaceRegistryPaths(
roots: PluginMetadataRoots,
configDir?: string
): void {
const successMessage = 'Synchronized marketplace registry paths';
const warningLabel = 'marketplace registry';
try {
const sourcePaths = getMarketplaceRegistrySourcePaths(roots, configDir);
writePluginMetadataFile(
path.join(roots.claudeDir, 'plugins', 'known_marketplaces.json'),
buildMarketplaceRegistryContent(sourcePaths, roots.claudeDir),
successMessage
);
if (configDir && path.resolve(configDir) !== path.resolve(roots.claudeDir)) {
writePluginMetadataFile(
path.join(configDir, 'plugins', 'known_marketplaces.json'),
buildMarketplaceRegistryContent(sourcePaths, configDir),
successMessage
);
}
} catch (err) {
console.log(warn(`Could not synchronize ${warningLabel}: ${(err as Error).message}`));
}
}
/**
* Run normalizePluginMetadataFile across every registry path deduped by
* canonical realpath.
*/
function normalizePluginMetadataFiles(
roots: PluginMetadataRoots,
fileName: string,
configDir: string | undefined,
successMessage: string,
warningLabel: string
): void {
const seen = new Set<string>();
for (const registryPath of getPluginMetadataFilePaths(roots, fileName, configDir)) {
const dedupeKey = resolveCanonicalPath(registryPath);
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
normalizePluginMetadataFile(registryPath, successMessage, warningLabel);
}
}
function getPluginMetadataFilePaths(
roots: PluginMetadataRoots,
fileName: string,
configDir?: string
): string[] {
const pluginDirs = new Set<string>([
path.join(roots.claudeDir, 'plugins'),
path.join(roots.sharedDir, 'plugins'),
]);
if (configDir && path.resolve(configDir) !== path.resolve(roots.claudeDir)) {
pluginDirs.add(path.join(configDir, 'plugins'));
}
return [...pluginDirs].map((pluginDir) => path.join(pluginDir, fileName));
}
function normalizePluginMetadataFile(
registryPath: string,
successMessage: string,
warningLabel: string
): void {
if (!fs.existsSync(registryPath)) {
return;
}
try {
const original = fs.readFileSync(registryPath, 'utf8');
const normalized = normalizePluginMetadataContent(original);
if (normalized !== original) {
fs.writeFileSync(registryPath, normalized, 'utf8');
console.log(ok(successMessage));
}
} catch (err) {
console.log(warn(`Could not normalize ${warningLabel}: ${(err as Error).message}`));
}
}
function getMarketplaceRegistrySourcePaths(
roots: PluginMetadataRoots,
configDir?: string
): string[] {
const sourcePaths = new Set<string>([
path.join(roots.claudeDir, 'plugins', 'known_marketplaces.json'),
]);
for (const instancePath of listAccountInstancePaths(roots.instancesDir)) {
sourcePaths.add(path.join(instancePath, 'plugins', 'known_marketplaces.json'));
}
if (configDir && path.resolve(configDir) !== path.resolve(roots.claudeDir)) {
sourcePaths.add(path.join(configDir, 'plugins', 'known_marketplaces.json'));
}
return [...sourcePaths];
}
function buildMarketplaceRegistryContent(sourcePaths: string[], targetConfigDir: string): string {
const merged: Record<string, unknown> = {};
for (const registryPath of sourcePaths) {
if (!fs.existsSync(registryPath)) {
continue;
}
try {
const parsed = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
continue;
}
for (const [name, value] of Object.entries(parsed as Record<string, unknown>)) {
if (!isMarketplaceRegistryEntry(value)) {
continue;
}
merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized;
}
} catch (err) {
console.log(
warn(`Skipping malformed marketplace registry ${registryPath}: ${(err as Error).message}`)
);
}
}
const discoveredEntries = discoverMarketplaceEntries(targetConfigDir);
// Keep only registry entries that have a physical directory, and update
// their installLocation. Entries only on disk (no registry record) are
// excluded: they lack required schema fields that Claude Code enforces.
for (const name of Object.keys(merged)) {
const entry = merged[name];
if (!(name in discoveredEntries)) {
delete merged[name];
} else if (isMarketplaceRegistryEntry(entry)) {
merged[name] = {
...entry,
installLocation: discoveredEntries[name].installLocation,
};
} else {
delete merged[name];
}
}
return JSON.stringify(merged, null, 2);
}
/**
* Discover physical marketplace directories on disk. Skips hidden dirs and
* Claude Code rename-dance leftovers (.staging/.bak).
*/
export function discoverMarketplaceEntries(
targetConfigDir: string
): Record<string, { installLocation: string }> {
const marketplacesDir = path.join(targetConfigDir, 'plugins', 'marketplaces');
if (!fs.existsSync(marketplacesDir)) {
return {};
}
const discovered: Record<string, { installLocation: string }> = {};
for (const entry of fs.readdirSync(marketplacesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
if (isTransientMarketplaceDirectory(entry.name)) {
continue;
}
discovered[entry.name] = {
installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name),
};
}
return discovered;
}
function isTransientMarketplaceDirectory(name: string): boolean {
return name.startsWith('.') || name.endsWith('.staging') || name.endsWith('.bak');
}
function isMarketplaceRegistryEntry(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
/**
* Write a plugin metadata file, creating parent dirs first and skipping
* when the content is unchanged.
*/
export function writePluginMetadataFile(
registryPath: string,
content: string,
successMessage: string
): void {
fs.mkdirSync(path.dirname(registryPath), { recursive: true, mode: 0o700 });
const current = fs.existsSync(registryPath) ? fs.readFileSync(registryPath, 'utf8') : null;
if (current === content) {
return;
}
fs.writeFileSync(registryPath, content, 'utf8');
console.log(ok(successMessage));
}
/**
* Reconcile the local marketplace registry against on-disk directories,
* removing the registry file entirely when no marketplaces remain and
* otherwise re-normalizing each entry.
*/
export function reconcileLocalMarketplaceRegistry(
_roots: PluginMetadataRoots,
configDir: string
): void {
const registryPath = path.join(configDir, 'plugins', 'known_marketplaces.json');
if (!fs.existsSync(registryPath)) {
return;
}
const discoveredEntries = discoverMarketplaceEntries(configDir);
if (Object.keys(discoveredEntries).length === 0) {
removeExistingPath(registryPath, 'file');
return;
}
let parsed: Record<string, unknown> = {};
try {
const raw = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as unknown;
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
parsed = raw as Record<string, unknown>;
}
} catch {
parsed = {};
}
const reconciled = Object.fromEntries(
Object.entries(discoveredEntries).map(([name, value]) => {
const existing = parsed[name];
if (existing && typeof existing === 'object' && !Array.isArray(existing)) {
return [
name,
{
...(normalizePluginMetadataValue(existing, configDir).normalized as Record<
string,
unknown
>),
installLocation: value.installLocation,
},
];
}
return [name, value];
})
);
writePluginMetadataFile(
registryPath,
JSON.stringify(reconciled, null, 2),
'Synchronized marketplace registry paths'
);
}
// Silence unused-import warning for SharedItem when consumers re-import it.
export type { SharedItem };
@@ -0,0 +1,298 @@
/**
* SharedManager - project context, memory, and continuity synchronization.
*
* Extracted from the original monolithic shared-manager.ts. Owns the
* account-policy driven layout for per-instance projects/, memory/, and
* advanced continuity artifacts (session-env, file-history, shell-snapshots,
* todos).
*
* All filesystem roots are passed explicitly. The SharedManager orchestrator
* is responsible for supplying its own private fields and the warn/ok/info
* UI helpers.
*/
import * as fs from 'fs';
import * as path from 'path';
import { DEFAULT_ACCOUNT_CONTEXT_GROUP } from '../../auth/account-context';
import type { AccountContextPolicy } from '../../auth/account-context';
import { warn } from '../../utils/ui';
import { ensureDirectory, getLstat, pathExists } from './fs-helpers';
import {
detachLegacySharedMemoryLinks,
isSafeContinuityMergeSource,
isSafeProjectsMergeSource,
isSymlinkTarget,
linkDirectoryWithFallback,
mergeDirectoryWithConflictCopies,
resolveSymlinkTargetPath,
type SymlinkHelperDeps,
} from './symlink-helpers';
import { ADVANCED_CONTINUITY_ITEMS } from './types';
/**
* Roots for the context sync module. Matches the linker/metadata shape.
*/
export interface ContextSyncRoots {
sharedDir: string;
instancesDir: string;
}
/**
* Sync project workspace context based on account policy.
*
* - isolated (default): each profile keeps its own ./projects directory.
* - shared: profile ./projects becomes symlink to shared context group root.
*/
export async function syncProjectContext(
roots: ContextSyncRoots,
instancePath: string,
policy: AccountContextPolicy,
deps: SymlinkHelperDeps
): Promise<void> {
const projectsPath = path.join(instancePath, 'projects');
const instanceName = path.basename(instancePath);
const mode = policy.mode === 'shared' ? 'shared' : 'isolated';
if (mode === 'shared') {
const contextGroup = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP;
const sharedProjectsPath = path.join(
roots.sharedDir,
'context-groups',
contextGroup,
'projects'
);
await ensureDirectory(sharedProjectsPath);
await ensureDirectory(path.dirname(projectsPath));
const currentStats = await getLstat(projectsPath);
if (!currentStats) {
await linkDirectoryWithFallback(sharedProjectsPath, projectsPath, deps);
return;
}
if (currentStats.isSymbolicLink()) {
if (await isSymlinkTarget(projectsPath, sharedProjectsPath)) {
return;
}
const currentTarget = await resolveSymlinkTargetPath(projectsPath);
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(sharedProjectsPath) &&
isSafeProjectsMergeSource(
currentTarget,
instanceName,
roots.sharedDir,
roots.instancesDir
) &&
(await pathExists(currentTarget))
) {
await mergeDirectoryWithConflictCopies(currentTarget, sharedProjectsPath, instanceName);
} else if (
currentTarget &&
!isSafeProjectsMergeSource(currentTarget, instanceName, roots.sharedDir, roots.instancesDir)
) {
console.log(
warn(`Skipping unsafe project merge source outside CCS roots: ${currentTarget}`)
);
}
await fs.promises.unlink(projectsPath);
await linkDirectoryWithFallback(sharedProjectsPath, projectsPath, deps);
return;
}
if (currentStats.isDirectory()) {
await detachLegacySharedMemoryLinks(projectsPath, instanceName, roots.sharedDir);
await mergeDirectoryWithConflictCopies(projectsPath, sharedProjectsPath, instanceName);
await fs.promises.rm(projectsPath, { recursive: true, force: true });
await linkDirectoryWithFallback(sharedProjectsPath, projectsPath, deps);
return;
}
await fs.promises.rm(projectsPath, { force: true });
await linkDirectoryWithFallback(sharedProjectsPath, projectsPath, deps);
return;
}
const currentStats = await getLstat(projectsPath);
if (!currentStats) {
await ensureDirectory(projectsPath);
return;
}
if (currentStats.isDirectory()) {
await detachLegacySharedMemoryLinks(projectsPath, instanceName, roots.sharedDir);
return;
}
if (currentStats.isSymbolicLink()) {
const currentTarget = await resolveSymlinkTargetPath(projectsPath);
await fs.promises.unlink(projectsPath);
await ensureDirectory(projectsPath);
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(projectsPath) &&
isSafeProjectsMergeSource(currentTarget, instanceName, roots.sharedDir, roots.instancesDir) &&
(await pathExists(currentTarget))
) {
await mergeDirectoryWithConflictCopies(currentTarget, projectsPath, instanceName);
} else if (
currentTarget &&
!isSafeProjectsMergeSource(currentTarget, instanceName, roots.sharedDir, roots.instancesDir)
) {
console.log(warn(`Skipping unsafe project merge source outside CCS roots: ${currentTarget}`));
}
return;
}
await fs.promises.rm(projectsPath, { force: true });
await ensureDirectory(projectsPath);
}
/**
* Sync advanced continuity artifacts for shared deeper mode.
*
* - shared + deeper: artifacts are linked per context group.
* - shared + standard / isolated: artifacts stay local to instance.
*/
export async function syncAdvancedContinuityArtifacts(
roots: ContextSyncRoots,
instancePath: string,
policy: AccountContextPolicy,
deps: SymlinkHelperDeps
): Promise<void> {
const instanceName = path.basename(instancePath);
const useSharedContinuity = policy.mode === 'shared' && policy.continuityMode === 'deeper';
const contextGroup = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP;
for (const artifactName of ADVANCED_CONTINUITY_ITEMS) {
const instanceArtifactPath = path.join(instancePath, artifactName);
if (useSharedContinuity) {
const sharedArtifactPath = path.join(
roots.sharedDir,
'context-groups',
contextGroup,
'continuity',
artifactName
);
await ensureDirectory(sharedArtifactPath);
await ensureDirectory(path.dirname(instanceArtifactPath));
const currentStats = await getLstat(instanceArtifactPath);
if (!currentStats) {
await linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath, deps);
continue;
}
if (currentStats.isSymbolicLink()) {
if (await isSymlinkTarget(instanceArtifactPath, sharedArtifactPath)) {
continue;
}
const currentTarget = await resolveSymlinkTargetPath(instanceArtifactPath);
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(sharedArtifactPath) &&
isSafeContinuityMergeSource(
currentTarget,
instanceName,
artifactName,
roots.sharedDir,
roots.instancesDir
) &&
(await pathExists(currentTarget))
) {
await mergeDirectoryWithConflictCopies(currentTarget, sharedArtifactPath, instanceName);
} else if (
currentTarget &&
!isSafeContinuityMergeSource(
currentTarget,
instanceName,
artifactName,
roots.sharedDir,
roots.instancesDir
)
) {
console.log(
warn(`Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}`)
);
}
await fs.promises.unlink(instanceArtifactPath);
await linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath, deps);
continue;
}
if (currentStats.isDirectory()) {
await mergeDirectoryWithConflictCopies(
instanceArtifactPath,
sharedArtifactPath,
instanceName
);
await fs.promises.rm(instanceArtifactPath, { recursive: true, force: true });
await linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath, deps);
continue;
}
await fs.promises.rm(instanceArtifactPath, { force: true });
await linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath, deps);
continue;
}
const currentStats = await getLstat(instanceArtifactPath);
if (!currentStats) {
await ensureDirectory(instanceArtifactPath);
continue;
}
if (currentStats.isDirectory()) {
continue;
}
if (currentStats.isSymbolicLink()) {
const currentTarget = await resolveSymlinkTargetPath(instanceArtifactPath);
await fs.promises.unlink(instanceArtifactPath);
await ensureDirectory(instanceArtifactPath);
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(instanceArtifactPath) &&
isSafeContinuityMergeSource(
currentTarget,
instanceName,
artifactName,
roots.sharedDir,
roots.instancesDir
) &&
(await pathExists(currentTarget))
) {
await mergeDirectoryWithConflictCopies(currentTarget, instanceArtifactPath, instanceName);
} else if (
currentTarget &&
!isSafeContinuityMergeSource(
currentTarget,
instanceName,
artifactName,
roots.sharedDir,
roots.instancesDir
)
) {
console.log(
warn(`Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}`)
);
}
continue;
}
await fs.promises.rm(instanceArtifactPath, { force: true });
await ensureDirectory(instanceArtifactPath);
}
}
@@ -0,0 +1,117 @@
/**
* SharedManager - per-project memory directory synchronization.
*
* Extracted from project-context-sync.ts to keep each sync concern under
* the 400 LOC target. Owns the layout migration from per-instance
* projects/<project>/memory/ into the canonical shared memory root at
* ~/.ccs/shared/memory/<project>/.
*/
import * as fs from 'fs';
import * as path from 'path';
import { ok } from '../../utils/ui';
import { ensureDirectory, getLstat, moveDirectory, pathExists } from './fs-helpers';
import {
ensureProjectMemoryLink,
isSymlinkTarget,
mergeDirectoryWithConflictCopies,
type SymlinkHelperDeps,
} from './symlink-helpers';
import type { ContextSyncRoots } from './project-context-sync';
/**
* Ensure all project memory directories for an instance are shared.
*
* Source layout (isolated):
* ~/.ccs/instances/<profile>/projects/<project>/memory/
*
* Shared layout (canonical):
* ~/.ccs/shared/memory/<project>/
*/
export async function syncProjectMemories(
roots: ContextSyncRoots,
instancePath: string,
deps: SymlinkHelperDeps
): Promise<void> {
const projectsDir = path.join(instancePath, 'projects');
if (!(await pathExists(projectsDir))) {
return;
}
await ensureDirectory(roots.sharedDir);
const sharedMemoryRoot = path.join(roots.sharedDir, 'memory');
await ensureDirectory(sharedMemoryRoot);
let projectEntries: fs.Dirent[] = [];
try {
projectEntries = await fs.promises.readdir(projectsDir, { withFileTypes: true });
} catch (_err) {
return;
}
const projects = projectEntries.filter((entry) => entry.isDirectory());
if (projects.length === 0) {
return;
}
let migrated = 0;
let merged = 0;
let linked = 0;
const instanceName = path.basename(instancePath);
for (const project of projects) {
const projectDir = path.join(projectsDir, project.name);
const projectMemoryPath = path.join(projectDir, 'memory');
const sharedProjectMemoryPath = path.join(sharedMemoryRoot, project.name);
const projectMemoryStats = await getLstat(projectMemoryPath);
if (!projectMemoryStats) {
if (await ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath, deps)) {
linked++;
}
continue;
}
if (projectMemoryStats.isSymbolicLink()) {
if (await isSymlinkTarget(projectMemoryPath, sharedProjectMemoryPath)) {
continue;
}
await fs.promises.unlink(projectMemoryPath);
if (await ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath, deps)) {
linked++;
}
continue;
}
if (!projectMemoryStats.isDirectory()) {
continue;
}
if (!(await pathExists(sharedProjectMemoryPath))) {
await moveDirectory(projectMemoryPath, sharedProjectMemoryPath);
migrated++;
} else {
merged += await mergeDirectoryWithConflictCopies(
projectMemoryPath,
sharedProjectMemoryPath,
instanceName
);
await fs.promises.rm(projectMemoryPath, { recursive: true, force: true });
}
if (await ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath, deps)) {
linked++;
}
}
if (migrated > 0 || merged > 0 || linked > 0) {
console.log(
ok(
`Synced shared project memory: ${migrated} migrated, ${merged} merged conflict(s), ${linked} linked`
)
);
}
}
@@ -0,0 +1,242 @@
/**
* SharedManager - shared directory linking entrypoints.
*
* Extracted from the original monolithic shared-manager.ts. Owns the
* creation of the ~/.ccs/shared/* symlinks pointing at ~/.claude/* and
* the per-instance links for commands/skills/agents/plugins/settings.
*
* Plugin-layout internals (the four functions that operate on the plugins/
* subtree) live in plugin-layout-internals.ts. This file keeps only the
* high-level entrypoints and circular-symlink detection so each file stays
* focused and under the 400 LOC target.
*
* These functions take an explicit roots object so they remain decoupled
* from SharedManager instance state.
*/
import * as fs from 'fs';
import * as path from 'path';
import { info, warn } from '../../utils/ui';
import {
copyDirectoryFallback,
getLstatSync,
isPathWithinDirectory,
removeExistingPath,
resolveCanonicalPath,
symlinkPointsTo,
} from './fs-helpers';
import type { PluginMetadataRoots } from './plugin-metadata-normalizer';
import {
normalizeMarketplaceRegistryPaths,
normalizePluginRegistryPaths,
} from './plugin-metadata-normalizer';
import {
detachManagedPluginLayout,
ensureSharedPluginLayoutDefaults,
linkInstancePlugins,
} from './plugin-layout-internals';
import { SHARED_ITEMS } from './types';
/**
* Roots for the shared-dir linker. Reuses PluginMetadataRoots because the
* linker operates on the same three roots.
*/
export type LinkerRoots = PluginMetadataRoots;
/**
* Detect a circular symlink before creation. A symlink is circular when its
* target (raw or canonical) points back inside the shared root.
*/
export function detectCircularSymlink(target: string, sharedDir: string): boolean {
try {
const stats = fs.lstatSync(target);
if (!stats.isSymbolicLink()) {
return false;
}
const targetLink = fs.readlinkSync(target);
const resolvedTarget = path.resolve(path.dirname(target), targetLink);
const sharedDirPath = path.resolve(sharedDir);
// A raw target path pointing back into ~/.ccs/shared is already unsafe.
// Re-pointing ~/.ccs/shared/* to ~/.claude/* would turn it into a real
// loop, even if the current ~/.ccs/shared entry ultimately resolves to
// an external path.
if (isPathWithinDirectory(resolvedTarget, sharedDirPath)) {
console.log(warn(`Circular symlink detected: ${target} → ${resolvedTarget}`));
return true;
}
// Only treat targets inside the managed shared root as circular.
// Existing shared symlinks may already resolve through ~/.claude/ to an
// external repo, which is a supported upgrade path rather than a loop.
const sharedDirCanonical = resolveCanonicalPath(sharedDirPath);
const canonicalResolvedTarget = resolveCanonicalPath(resolvedTarget);
if (isPathWithinDirectory(canonicalResolvedTarget, sharedDirCanonical)) {
console.log(warn(`Circular symlink detected: ${target} → ${resolvedTarget}`));
return true;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return false;
}
throw err;
}
return false;
}
/**
* Ensure shared directories exist as symlinks to ~/.claude/ and that the
* plugin layout default directories and registry files are present.
*/
export function ensureSharedDirectories(roots: LinkerRoots): void {
const claudeDir = roots.claudeDir;
const sharedDir = roots.sharedDir;
if (!getLstatSync(claudeDir)) {
console.log(info('Creating ~/.claude/ directory structure'));
fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 });
}
if (!getLstatSync(sharedDir)) {
fs.mkdirSync(sharedDir, { recursive: true, mode: 0o700 });
}
ensureSharedPluginLayoutDefaults(claudeDir);
for (const item of SHARED_ITEMS) {
const claudePath = path.join(claudeDir, item.name);
const sharedPath = path.join(sharedDir, item.name);
if (!getLstatSync(claudePath)) {
if (item.type === 'directory') {
fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 });
} else if (item.type === 'file') {
fs.writeFileSync(claudePath, JSON.stringify({}, null, 2), 'utf8');
}
}
if (detectCircularSymlink(claudePath, sharedDir)) {
console.log(warn(`Skipping ${item.name}: circular symlink detected`));
continue;
}
if (getLstatSync(sharedPath)) {
try {
const stats = fs.lstatSync(sharedPath);
if (stats.isSymbolicLink()) {
const currentTarget = fs.readlinkSync(sharedPath);
const resolvedTarget = path.resolve(path.dirname(sharedPath), currentTarget);
if (resolvedTarget === claudePath) {
continue;
}
}
} catch (_err) {
// Continue to recreate
}
if (item.type === 'directory') {
fs.rmSync(sharedPath, { recursive: true, force: true });
} else {
fs.unlinkSync(sharedPath);
}
}
try {
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
fs.symlinkSync(claudePath, sharedPath, symlinkType);
} catch (_err) {
if (process.platform === 'win32') {
if (item.type === 'directory') {
copyDirectoryFallback(claudePath, sharedPath);
} else if (item.type === 'file') {
fs.copyFileSync(claudePath, sharedPath);
}
console.log(
warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`)
);
} else {
throw _err;
}
}
}
}
/**
* Link shared directories into a specific instance path.
*/
export function linkSharedDirectories(roots: LinkerRoots, instancePath: string): void {
ensureSharedDirectories(roots);
const sharedDir = roots.sharedDir;
for (const item of SHARED_ITEMS) {
if (item.name === 'plugins') {
linkInstancePlugins(roots, instancePath);
continue;
}
const linkPath = path.join(instancePath, item.name);
const targetPath = path.join(sharedDir, item.name);
removeExistingPath(linkPath, item.type);
try {
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
fs.symlinkSync(targetPath, linkPath, symlinkType);
} catch (_err) {
if (process.platform === 'win32') {
if (item.type === 'directory') {
copyDirectoryFallback(targetPath, linkPath);
} else if (item.type === 'file') {
fs.copyFileSync(targetPath, linkPath);
}
console.log(
warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`)
);
} else {
throw _err;
}
}
}
// Preserve original behavior: linkSharedDirectories always concludes by
// normalizing plugin + marketplace metadata for the freshly linked
// instance. migrateFromV311 relies on this side effect.
normalizePluginRegistryPaths(roots, instancePath);
normalizeMarketplaceRegistryPaths(roots, instancePath);
}
/**
* Detach shared-directory symlinks from an instance, removing only entries
* that point back at the shared root.
*/
export function detachSharedDirectories(roots: LinkerRoots, instancePath: string): void {
ensureSharedDirectories(roots);
const sharedDir = roots.sharedDir;
for (const item of SHARED_ITEMS) {
const managedPath = path.join(instancePath, item.name);
if (!fs.existsSync(managedPath)) {
continue;
}
if (item.name === 'plugins') {
detachManagedPluginLayout(roots, instancePath);
continue;
}
const stats = fs.lstatSync(managedPath);
if (!stats.isSymbolicLink()) {
continue;
}
if (symlinkPointsTo(managedPath, path.join(sharedDir, item.name))) {
removeExistingPath(managedPath, item.type);
}
}
}
@@ -0,0 +1,296 @@
/**
* SharedManager - async symlink and merge helpers.
*
* Extracted from the original monolithic shared-manager.ts. These functions
* handle the cross-platform symlink + recursive merge primitives that the
* project-context-sync and project-memory flows build on.
*
* Logging is dependency-injected via the SymlinkHelperDeps interface so this
* module stays free of imports from the UI layer. The orchestrator class is
* responsible for supplying `warn` from `../utils/ui`.
*/
import * as fs from 'fs';
import * as path from 'path';
import {
copyDirectoryFallback,
ensureDirectory,
fileContentsEqual,
getConflictCopyPath,
pathExists,
resolveCanonicalPath,
isPathWithinDirectory,
} from './fs-helpers';
/**
* Dependencies that the orchestrator (SharedManager) must supply to the
* symlink helpers. Injected rather than imported so this module has no
* coupling to the UI layer.
*/
export interface SymlinkHelperDeps {
warn: (message: string) => string;
}
/**
* Check whether a symlink points at an expected target. Returns false on any
* IO error or when the link is not a symlink.
*/
export async function isSymlinkTarget(linkPath: string, expectedTarget: string): Promise<boolean> {
try {
const stats = await fs.promises.lstat(linkPath);
if (!stats.isSymbolicLink()) {
return false;
}
const currentTarget = await fs.promises.readlink(linkPath);
const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget);
const resolvedExpectedTarget = path.resolve(expectedTarget);
return resolvedCurrentTarget === resolvedExpectedTarget;
} catch (_err) {
return false;
}
}
/**
* Resolve a symlink to its absolute target path. Returns null on failure.
*/
export async function resolveSymlinkTargetPath(linkPath: string): Promise<string | null> {
try {
const currentTarget = await fs.promises.readlink(linkPath);
return path.resolve(path.dirname(linkPath), currentTarget);
} catch (_err) {
return null;
}
}
/**
* Create a symlink from linkPath to targetPath, falling back to a recursive
* copy on Windows when symlink creation fails.
*/
export async function linkDirectoryWithFallback(
targetPath: string,
linkPath: string,
deps: SymlinkHelperDeps
): Promise<void> {
const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir';
const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath;
try {
await fs.promises.symlink(linkTarget, linkPath, symlinkType);
} catch (_err) {
if (process.platform === 'win32') {
copyDirectoryFallback(targetPath, linkPath);
console.log(
deps.warn(`Symlink failed for context projects, copied instead (enable Developer Mode)`)
);
return;
}
throw _err;
}
}
/**
* Ensure a symlink from linkPath to targetPath exists, creating the target
* directory first if needed. Returns true when a new link/copy was created
* or an existing incorrect link was replaced.
*/
export async function ensureProjectMemoryLink(
linkPath: string,
targetPath: string,
deps: SymlinkHelperDeps
): Promise<boolean> {
await ensureDirectory(targetPath);
let linkStats: fs.Stats | null = null;
try {
linkStats = await fs.promises.lstat(linkPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err;
}
}
if (linkStats) {
if (linkStats.isSymbolicLink() && (await isSymlinkTarget(linkPath, targetPath))) {
return false;
}
if (linkStats.isDirectory()) {
await fs.promises.rm(linkPath, { recursive: true, force: true });
} else {
await fs.promises.unlink(linkPath);
}
}
const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir';
const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath;
try {
await fs.promises.symlink(linkTarget, linkPath, symlinkType);
return true;
} catch (_err) {
if (process.platform === 'win32') {
copyDirectoryFallback(targetPath, linkPath);
console.log(
deps.warn(`Symlink failed for project memory, copied instead (enable Developer Mode)`)
);
return true;
}
throw _err;
}
}
/**
* Merge sourceDir into targetDir recursively. On file conflicts the target
* is preserved and the source file is copied as
* "<name>.migrated-from-<instance>[-N]" to avoid data loss. Returns the
* number of conflict copies produced.
*/
export async function mergeDirectoryWithConflictCopies(
sourceDir: string,
targetDir: string,
instanceName: string
): Promise<number> {
await ensureDirectory(targetDir);
let conflicts = 0;
const entries = await fs.promises.readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
conflicts += await mergeDirectoryWithConflictCopies(sourcePath, targetPath, instanceName);
continue;
}
if (entry.isFile()) {
if (!(await pathExists(targetPath))) {
await fs.promises.copyFile(sourcePath, targetPath);
continue;
}
if (await fileContentsEqual(sourcePath, targetPath)) {
continue;
}
const conflictPath = await getConflictCopyPath(targetPath, instanceName);
await fs.promises.copyFile(sourcePath, conflictPath);
conflicts++;
}
}
return conflicts;
}
/**
* Migrate legacy per-project memory symlinks that point into the shared
* memory root back to instance-local directories, preserving data via
* conflict-copy merge.
*/
export async function detachLegacySharedMemoryLinks(
projectsPath: string,
instanceName: string,
sharedDir: string
): Promise<void> {
const sharedMemoryRoot = resolveCanonicalPath(path.join(sharedDir, 'memory'));
let projectEntries: fs.Dirent[] = [];
try {
projectEntries = await fs.promises.readdir(projectsPath, { withFileTypes: true });
} catch (_err) {
return;
}
for (const entry of projectEntries) {
if (!entry.isDirectory()) {
continue;
}
const projectPath = path.join(projectsPath, entry.name);
const memoryPath = path.join(projectPath, 'memory');
let memoryStats: fs.Stats | null = null;
try {
memoryStats = await fs.promises.lstat(memoryPath);
} catch (_err) {
memoryStats = null;
}
if (!memoryStats?.isSymbolicLink()) {
continue;
}
const memoryTarget = await resolveSymlinkTargetPath(memoryPath);
if (!memoryTarget) {
continue;
}
const canonicalMemoryTarget = resolveCanonicalPath(memoryTarget);
if (!isPathWithinDirectory(canonicalMemoryTarget, sharedMemoryRoot)) {
continue;
}
await fs.promises.unlink(memoryPath);
await ensureDirectory(memoryPath);
if (await pathExists(canonicalMemoryTarget)) {
await mergeDirectoryWithConflictCopies(canonicalMemoryTarget, memoryPath, instanceName);
}
}
}
/**
* Guard project merge operations to known CCS-managed roots only.
*/
export function isSafeProjectsMergeSource(
sourcePath: string,
instanceName: string,
sharedDir: string,
instancesDir: string
): boolean {
const resolvedSource = resolveCanonicalPath(sourcePath);
const sharedContextRoot = resolveCanonicalPath(path.join(sharedDir, 'context-groups'));
const instanceProjectsRoot = resolveCanonicalPath(
path.join(instancesDir, instanceName, 'projects')
);
return (
isPathWithinDirectory(resolvedSource, sharedContextRoot) ||
isPathWithinDirectory(resolvedSource, instanceProjectsRoot)
);
}
/**
* Guard advanced continuity merge operations to known CCS-managed roots only.
*/
export function isSafeContinuityMergeSource(
sourcePath: string,
instanceName: string,
artifactName: string,
sharedDir: string,
instancesDir: string
): boolean {
const resolvedSource = resolveCanonicalPath(sourcePath);
const sharedContextRoot = resolveCanonicalPath(path.join(sharedDir, 'context-groups'));
const instanceArtifactRoot = resolveCanonicalPath(
path.join(instancesDir, instanceName, artifactName)
);
const normalizedSource =
process.platform === 'win32' ? resolvedSource.toLowerCase() : resolvedSource;
const continuitySegment =
process.platform === 'win32'
? `${path.sep}continuity${path.sep}`.toLowerCase()
: `${path.sep}continuity${path.sep}`;
const withinSharedContinuity =
isPathWithinDirectory(resolvedSource, sharedContextRoot) &&
normalizedSource.includes(continuitySegment);
return withinSharedContinuity || isPathWithinDirectory(resolvedSource, instanceArtifactRoot);
}
+72
View File
@@ -0,0 +1,72 @@
/**
* SharedManager - shared types and constants.
*
* Extracted from the original monolithic shared-manager.ts to keep the
* orchestrator class focused on coordination. Pure data only: no behavior
* lives here.
*/
/**
* Descriptor for a filesystem entry (directory or file) managed by
* SharedManager. Used for both top-level shared items and plugin layout
* entries.
*/
export interface SharedItem {
name: string;
type: 'directory' | 'file';
}
/**
* Default content for a freshly provisioned installed_plugins.json registry.
* Version 2 schema with an empty plugins map.
*/
export const DEFAULT_INSTALLED_PLUGIN_REGISTRY = JSON.stringify(
{
version: 2,
plugins: {},
},
null,
2
);
/**
* Canonical list of shared items linked between ~/.claude and ~/.ccs/shared,
* and from there into each instance.
*
* Order matters: consumers rely on a stable iteration order when reconciling
* symlinks, and 'plugins' is special-cased by the linker.
*/
export const SHARED_ITEMS: readonly SharedItem[] = [
{ name: 'commands', type: 'directory' },
{ name: 'skills', type: 'directory' },
{ name: 'agents', type: 'directory' },
{ name: 'plugins', type: 'directory' },
{ name: 'settings.json', type: 'file' },
];
/**
* Plugin layout entries that always exist under ~/.claude/plugins and are
* linked as-is into each instance's plugins directory.
*/
export const SHARED_PLUGIN_ENTRIES: readonly SharedItem[] = [
{ name: 'cache', type: 'directory' },
{ name: 'marketplaces', type: 'directory' },
{ name: 'installed_plugins.json', type: 'file' },
];
/**
* Plugin metadata filenames that are intentionally instance-local and must
* NOT be linked from the shared plugins directory.
*/
export const INSTANCE_LOCAL_PLUGIN_METADATA_FILES = new Set(['known_marketplaces.json']);
/**
* Advanced continuity artifacts linked per context group when the account
* policy requests shared + deeper continuity.
*/
export const ADVANCED_CONTINUITY_ITEMS: readonly string[] = [
'session-env',
'file-history',
'shell-snapshots',
'todos',
];
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,241 @@
/**
* CLIProxy config + auth-files + model-update routes.
*
* - GET/PUT /config.yaml
* - GET /auth-files, GET /auth-files/download
* - PUT /models/:provider
*
* All require fs + path; extracted from the original god file to keep each
* submodule focused and well under the LOC budget.
*/
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import {
getCliproxyWritablePath,
getCliproxyConfigPath,
getAuthDir,
} from '../../../cliproxy/config/config-generator';
import {
MODEL_ENV_VAR_KEYS,
canonicalizeModelIdForProvider,
getDeniedModelIdReasonForProvider,
} from '../../../cliproxy/ai-providers/model-id-normalizer';
import { logger } from './shared';
/**
* Registers config, auth-files, and model-update routes on the given router.
*/
export function registerConfigRoutes(router: Router): void {
// ==================== Config File ====================
router.get('/config.yaml', async (_req: Request, res: Response): Promise<void> => {
try {
const configPath = getCliproxyConfigPath();
if (!fs.existsSync(configPath)) {
res.status(404).json({ error: 'Config file not found' });
return;
}
const content = fs.readFileSync(configPath, 'utf8');
res.type('text/yaml').send(content);
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
router.put('/config.yaml', async (req: Request, res: Response): Promise<void> => {
try {
const { content } = req.body;
if (typeof content !== 'string') {
res.status(400).json({ error: 'Missing required field: content' });
return;
}
const configPath = getCliproxyConfigPath();
const configDir = path.dirname(configPath);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
const tempPath = configPath + '.tmp';
fs.writeFileSync(tempPath, content);
fs.renameSync(tempPath, configPath);
res.json({ success: true, path: configPath });
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
// ==================== Auth Files ====================
router.get('/auth-files', async (_req: Request, res: Response): Promise<void> => {
try {
const authDir = getAuthDir();
if (!fs.existsSync(authDir)) {
res.json({ files: [] });
return;
}
const entries = fs.readdirSync(authDir, { withFileTypes: true });
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => {
const filePath = path.join(authDir, entry.name);
const stat = fs.statSync(filePath);
return {
name: entry.name,
size: stat.size,
mtime: stat.mtime.getTime(),
};
});
res.json({ files, directory: authDir });
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/auth-files/download', async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.query;
if (!name || typeof name !== 'string') {
res.status(400).json({ error: 'Missing required query parameter: name' });
return;
}
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
res.status(400).json({ error: 'Invalid filename' });
return;
}
const authDir = getAuthDir();
const filePath = path.join(authDir, name);
if (!fs.existsSync(filePath)) {
res.status(404).json({ error: 'Auth file not found' });
return;
}
const content = fs.readFileSync(filePath);
res.setHeader('Content-Disposition', `attachment; filename="${name}"`);
res.type('application/octet-stream').send(content);
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
// ==================== Model Updates ====================
router.put('/models/:provider', async (req: Request, res: Response): Promise<void> => {
try {
const { provider } = req.params;
if (!provider || provider.length > 64 || !/^[a-zA-Z0-9_-]+$/.test(provider)) {
res.status(400).json({ error: 'Invalid provider name' });
return;
}
const { model } = req.body;
if (!model || typeof model !== 'string') {
res.status(400).json({ error: 'Missing required field: model' });
return;
}
if (model.length > 256) {
res.status(400).json({ error: 'Model ID exceeds maximum length (256 characters)' });
return;
}
const deniedReason = getDeniedModelIdReasonForProvider(model, provider);
if (deniedReason) {
res.status(400).json({ error: deniedReason });
return;
}
const ccsDir = getCliproxyWritablePath();
const settingsPath = path.join(ccsDir, `${provider}.settings.json`);
if (!fs.existsSync(settingsPath)) {
res.status(404).json({ error: `Settings file not found for provider: ${provider}` });
return;
}
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
env?: Record<string, unknown>;
[key: string]: unknown;
};
const canonicalModel = canonicalizeModelIdForProvider(model, provider);
const env =
settings.env && typeof settings.env === 'object' && !Array.isArray(settings.env)
? settings.env
: {};
const previousDefault =
typeof env.ANTHROPIC_MODEL === 'string' ? env.ANTHROPIC_MODEL : canonicalModel;
const previousCanonicalDefault = canonicalizeModelIdForProvider(previousDefault, provider);
for (const key of MODEL_ENV_VAR_KEYS) {
if (key === 'ANTHROPIC_MODEL') {
env[key] = canonicalModel;
continue;
}
const current = env[key];
if (typeof current !== 'string') {
env[key] = canonicalModel;
continue;
}
const canonicalCurrent = canonicalizeModelIdForProvider(current, provider);
env[key] =
canonicalCurrent === previousCanonicalDefault ? canonicalModel : canonicalCurrent;
}
settings.env = env;
const tempPath = settingsPath + '.tmp';
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
fs.renameSync(tempPath, settingsPath);
res.json({ success: true, provider, model: canonicalModel });
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
}
@@ -0,0 +1,148 @@
/**
* CLIProxy error-log routes.
*
* - GET /error-logs (list + metadata extraction)
* - GET /error-logs/:name (single log content; path-traversal validated)
*/
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import {
isCliproxyRunning,
fetchCliproxyErrorLogs,
fetchCliproxyErrorLogContent,
} from '../../../cliproxy/services/stats-fetcher';
import { getCliproxyWritablePath } from '../../../cliproxy/config/config-generator';
import { logger } from './shared';
/**
* Extract status code and model from error log file (lightweight parsing).
* Reads first 4KB for model, last 2KB for status code. Async to avoid blocking event loop.
*/
async function extractErrorLogMetadata(
filePath: string
): Promise<{ statusCode?: number; model?: string }> {
let fh: fs.promises.FileHandle | null = null;
try {
fh = await fs.promises.open(filePath, 'r');
const stat = await fh.stat();
const fileSize = stat.size;
const startBuffer = Buffer.alloc(Math.min(4096, fileSize));
await fh.read(startBuffer, 0, startBuffer.length, 0);
const startContent = startBuffer.toString('utf-8');
const modelMatch = startContent.match(/"model"\s*:\s*"([^"]+)"/);
const model = modelMatch ? modelMatch[1] : undefined;
let statusCode: number | undefined;
if (fileSize > 2048) {
const endBuffer = Buffer.alloc(2048);
await fh.read(endBuffer, 0, 2048, fileSize - 2048);
const endContent = endBuffer.toString('utf-8');
const statusMatch = endContent.match(/Status:\s*(\d{3})/);
statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined;
} else {
const statusMatch = startContent.match(/Status:\s*(\d{3})/);
statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined;
}
return { statusCode, model };
} catch {
return {};
} finally {
await fh?.close();
}
}
/**
* Registers error-log routes on the given router.
*/
export function registerErrorLogRoutes(router: Router): void {
router.get('/error-logs', async (_req: Request, res: Response): Promise<void> => {
try {
const running = await isCliproxyRunning();
if (!running) {
res.status(503).json({
error: 'CLIProxy Plus not running',
message: 'Start a CLIProxy session to view error logs',
});
return;
}
const files = await fetchCliproxyErrorLogs();
if (files === null) {
res.status(503).json({
error: 'Error logs unavailable',
message: 'CLIProxy Plus is running but error logs endpoint not responding',
});
return;
}
const logsDir = path.join(getCliproxyWritablePath(), 'logs');
const filesWithMetadata = await Promise.all(
files.map(async (file) => {
const absolutePath = path.join(logsDir, file.name);
const metadata = await extractErrorLogMetadata(absolutePath);
return {
...file,
absolutePath,
statusCode: metadata.statusCode,
model: metadata.model,
};
})
);
res.json({ files: filesWithMetadata });
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/error-logs/:name', async (req: Request, res: Response): Promise<void> => {
const { name } = req.params;
if (
!name ||
!name.startsWith('error-') ||
!name.endsWith('.log') ||
name.includes('..') ||
name.includes('/') ||
name.includes('\\')
) {
res.status(400).json({ error: 'Invalid error log filename' });
return;
}
try {
const running = await isCliproxyRunning();
if (!running) {
res.status(503).json({ error: 'CLIProxy Plus not running' });
return;
}
const content = await fetchCliproxyErrorLogContent(name);
if (content === null) {
res.status(404).json({ error: 'Error log not found' });
return;
}
res.type('text/plain').send(content);
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
}
@@ -0,0 +1,36 @@
/**
* Quota caching helper. Determines whether a quota fetch result should be
* written to the shared in-memory cache.
*
* Public surface: `shouldCacheQuotaResult` is re-exported by the barrel.
*/
/**
* Cache only stable failures; skip transient network errors (timeouts, 429s, 5xx).
* Generic across all quota result types.
*/
export function shouldCacheQuotaResult(result: {
success: boolean;
needsReauth?: boolean;
isForbidden?: boolean;
httpStatus?: number;
retryable?: boolean;
error?: string;
}): boolean {
if (result.success) return true;
if (result.needsReauth || result.isForbidden) return true;
if (result.retryable === true) return false;
if (result.retryable === false) return true;
if (typeof result.httpStatus === 'number') {
if (result.httpStatus === 429 || result.httpStatus === 408 || result.httpStatus >= 500) {
return false;
}
if (result.httpStatus >= 400 && result.httpStatus < 500) {
return true;
}
}
const msg = (result.error || '').toLowerCase();
if (!msg) return false;
const transientPatterns = ['timeout', 'rate limited', 'api error: 5', 'fetch failed'];
return !transientPatterns.some((p) => msg.includes(p));
}
@@ -0,0 +1,231 @@
/**
* CLIProxy account-quota routes: codex, claude, gemini, ghcp, and the generic
* `/:provider/:accountId` fallback.
*
* Specific routes MUST be registered before the generic one for Express
* routing to match correctly.
*/
import { Router, Request, Response } from 'express';
import { fetchAccountQuota } from '../../../cliproxy/quota/quota-fetcher';
import { fetchCodexQuota } from '../../../cliproxy/quota/quota-fetcher-codex';
import { fetchClaudeQuota } from '../../../cliproxy/quota/quota-fetcher-claude';
import { fetchGeminiCliQuota } from '../../../cliproxy/quota/quota-fetcher-gemini-cli';
import { fetchGhcpQuota } from '../../../cliproxy/quota/quota-fetcher-ghcp';
import { getCachedQuota, setCachedQuota } from '../../../cliproxy/quota/quota-response-cache';
import type {
CodexQuotaResult,
ClaudeQuotaResult,
GeminiCliQuotaResult,
GhcpQuotaResult,
} from '../../../cliproxy/quota/quota-types';
import type { QuotaResult } from '../../../cliproxy/quota/quota-fetcher';
import type { CLIProxyProvider } from '../../../cliproxy/types';
import { CLIPROXY_PROFILES } from '../../../auth/profile-detector';
import { logger, isQuotaRouteRateLimited } from './shared';
import { shouldCacheQuotaResult } from './quota-helpers';
function replyRateLimited(res: Response): void {
res.status(429).json({ error: 'Too many quota requests', message: 'Retry after a short delay' });
}
function isInvalidAccountId(accountId: string | undefined): boolean {
return (
!accountId || accountId.includes('..') || accountId.includes('/') || accountId.includes('\\')
);
}
/**
* Registers all `/quota/*` routes on the given router.
*/
export function registerQuotaRoutes(router: Router): void {
router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promise<void> => {
const { accountId } = req.params;
if (isQuotaRouteRateLimited(req, 'codex')) {
replyRateLimited(res);
return;
}
if (isInvalidAccountId(accountId)) {
res.status(400).json({ error: 'Invalid account ID' });
return;
}
try {
const cached = getCachedQuota<CodexQuotaResult>('codex', accountId);
if (cached) {
res.json({ ...cached, cached: true });
return;
}
const result = await fetchCodexQuota(accountId);
if (shouldCacheQuotaResult(result)) {
setCachedQuota('codex', accountId, result);
}
res.json(result);
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/quota/claude/:accountId', async (req: Request, res: Response): Promise<void> => {
const { accountId } = req.params;
if (isQuotaRouteRateLimited(req, 'claude')) {
replyRateLimited(res);
return;
}
if (isInvalidAccountId(accountId)) {
res.status(400).json({ error: 'Invalid account ID' });
return;
}
try {
const cached = getCachedQuota<ClaudeQuotaResult>('claude', accountId);
if (cached) {
res.json({ ...cached, cached: true });
return;
}
const result = await fetchClaudeQuota(accountId);
if (shouldCacheQuotaResult(result)) {
setCachedQuota('claude', accountId, result);
}
res.json(result);
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Promise<void> => {
const { accountId } = req.params;
if (isQuotaRouteRateLimited(req, 'gemini')) {
replyRateLimited(res);
return;
}
if (isInvalidAccountId(accountId)) {
res.status(400).json({ error: 'Invalid account ID' });
return;
}
try {
const cached = getCachedQuota<GeminiCliQuotaResult>('gemini', accountId);
if (cached) {
res.json({ ...cached, cached: true });
return;
}
const result = await fetchGeminiCliQuota(accountId);
if (shouldCacheQuotaResult(result)) {
setCachedQuota('gemini', accountId, result);
}
res.json(result);
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promise<void> => {
const { accountId } = req.params;
if (isQuotaRouteRateLimited(req, 'ghcp')) {
replyRateLimited(res);
return;
}
if (isInvalidAccountId(accountId)) {
res.status(400).json({ error: 'Invalid account ID' });
return;
}
try {
const cached = getCachedQuota<GhcpQuotaResult>('ghcp', accountId);
if (cached) {
res.json({ ...cached, cached: true });
return;
}
const result = await fetchGhcpQuota(accountId);
if (shouldCacheQuotaResult(result)) {
setCachedQuota('ghcp', accountId, result);
}
res.json(result);
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise<void> => {
const { provider, accountId } = req.params;
if (isQuotaRouteRateLimited(req, provider)) {
replyRateLimited(res);
return;
}
const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
if (!validProviders.includes(provider as CLIProxyProvider)) {
res.status(400).json({
error: 'Invalid provider',
message: `Provider must be one of: ${validProviders.join(', ')}`,
});
return;
}
if (isInvalidAccountId(accountId)) {
res.status(400).json({ error: 'Invalid account ID' });
return;
}
try {
const cached = getCachedQuota<QuotaResult>(provider, accountId);
if (cached) {
res.json({ ...cached, cached: true });
return;
}
const result = await fetchAccountQuota(provider as CLIProxyProvider, accountId);
if (result.success) {
setCachedQuota(provider, accountId, result);
}
res.json(result);
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
}
@@ -0,0 +1,37 @@
/**
* POST /api/cliproxy/restart route registration.
*
* Public surface: `registerCliproxyRestartRoute` is re-exported by the barrel.
*/
import { Router, Request, Response } from 'express';
import { restartDashboardCliproxy } from '../../services/cliproxy-dashboard-restart-service';
import { logger } from './shared';
type RestartDashboardCliproxyHandler = typeof restartDashboardCliproxy;
/**
* Registers POST `/restart` on the given router.
*
* Handler shape and logger.error('stats.route.error', ...) conversion are
* preserved verbatim from the pre-split implementation.
*/
export function registerCliproxyRestartRoute(
targetRouter: Router,
restartHandler: RestartDashboardCliproxyHandler = restartDashboardCliproxy
): void {
targetRouter.post('/restart', async (_req: Request, res: Response): Promise<void> => {
try {
const result = await restartHandler();
res.json(result);
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
}
@@ -0,0 +1,290 @@
/**
* CLIProxy stats router: stats, status, proxy lifecycle, models, versions, and
* install endpoints. Delegates quota, error-log, config/auth-files/model-update,
* and restart routes to focused submodules.
*
* Default export is the Express Router. Re-exported as the barrel default.
*/
import { Router, Request, Response } from 'express';
import {
fetchCliproxyStats,
fetchCliproxyModels,
isCliproxyRunning,
} from '../../../cliproxy/services/stats-fetcher';
import {
getProxyStatus as getProxyProcessStatus,
stopProxy,
} from '../../../cliproxy/session-tracker';
import { ensureCliproxyService } from '../../../cliproxy/service-manager';
import { getStoredConfiguredBackend } from '../../../cliproxy/binary-manager';
import { isNewerVersion, isVersionFaulty } from '../../../cliproxy/binary/version-checker';
import {
CLIPROXY_MAX_STABLE_VERSION,
CLIPROXY_FAULTY_RANGE,
} from '../../../cliproxy/binary/platform-detector';
import { resolveLifecyclePort } from '../../../cliproxy/config/port-manager';
import { installDashboardCliproxyVersion } from '../../services/cliproxy-dashboard-install-service';
import { requireLocalAccessWhenAuthDisabled } from '../../middleware/auth-middleware';
import { logger } from './shared';
import {
resolveCliproxyUpdateCheckPayload,
resolveCliproxyVersionsPayload,
} from './version-helpers';
import { registerQuotaRoutes } from './quota-routes';
import { registerErrorLogRoutes } from './error-log-routes';
import { registerConfigRoutes } from './config-routes';
import { registerCliproxyRestartRoute } from './restart-route';
const router = Router();
router.use((req: Request, res: Response, next) => {
if (
requireLocalAccessWhenAuthDisabled(
req,
res,
'CLIProxy management endpoints require localhost access when dashboard auth is disabled.'
)
) {
next();
}
});
/**
* Shared handler for /stats and /usage endpoints.
*/
const handleStatsRequest = async (_req: Request, res: Response): Promise<void> => {
try {
const running = await isCliproxyRunning();
if (!running) {
res.status(503).json({
error: 'CLIProxy Plus not running',
message: 'Start a CLIProxy session (gemini, codex, claude, agy, ghcp) to collect stats',
});
return;
}
const stats = await fetchCliproxyStats();
if (!stats) {
res.status(503).json({
error: 'Stats unavailable',
message: 'CLIProxy Plus is running but stats endpoint not responding',
});
return;
}
res.json(stats);
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
};
router.get('/stats', handleStatsRequest);
router.get('/usage', handleStatsRequest);
router.get('/status', async (_req: Request, res: Response): Promise<void> => {
try {
const running = await isCliproxyRunning(resolveLifecyclePort());
res.json({ running });
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/proxy-status', async (_req: Request, res: Response): Promise<void> => {
try {
const port = resolveLifecyclePort();
const sessionStatus = getProxyProcessStatus(port);
if (sessionStatus.running) {
res.json(sessionStatus);
return;
}
const actuallyRunning = await isCliproxyRunning(port);
if (actuallyRunning) {
res.json({ running: true, port, sessionCount: 0 });
} else {
res.json(sessionStatus);
}
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/proxy-start', async (_req: Request, res: Response): Promise<void> => {
try {
const result = await ensureCliproxyService(resolveLifecyclePort());
res.json(result);
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/proxy-stop', async (_req: Request, res: Response): Promise<void> => {
try {
const result = await stopProxy(resolveLifecyclePort());
res.json(result);
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/update-check', async (_req: Request, res: Response): Promise<void> => {
try {
const backend = getStoredConfiguredBackend();
const result = await resolveCliproxyUpdateCheckPayload(backend);
res.json(result);
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/models', async (_req: Request, res: Response): Promise<void> => {
try {
const running = await isCliproxyRunning();
if (!running) {
res.status(503).json({
error: 'CLIProxy Plus not running',
message: 'Start a CLIProxy session (gemini, codex, claude, agy) to fetch available models',
});
return;
}
const modelsResponse = await fetchCliproxyModels();
if (!modelsResponse) {
res.status(503).json({
error: 'Models unavailable',
message: 'CLIProxy Plus is running but /v1/models endpoint not responding',
});
return;
}
res.json(modelsResponse);
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
// Delegated route groups. Registration order is preserved from the pre-split
// god file: error-logs, config/auth-files/model-update, quota, versions,
// install, restart.
registerErrorLogRoutes(router);
registerConfigRoutes(router);
registerQuotaRoutes(router);
router.get('/versions', async (_req: Request, res: Response): Promise<void> => {
try {
const backend = getStoredConfiguredBackend();
res.json(await resolveCliproxyVersionsPayload(backend));
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/install', async (req: Request, res: Response): Promise<void> => {
try {
const { version, force } = req.body;
if (!version || typeof version !== 'string') {
res.status(400).json({ error: 'Missing required field: version' });
return;
}
if (!/^\d+\.\d+\.\d+(-\d+)?$/.test(version)) {
res.status(400).json({ error: 'Invalid version format. Expected: X.Y.Z or X.Y.Z-N' });
return;
}
const isFaulty = isVersionFaulty(version);
const isExperimental = isNewerVersion(version, CLIPROXY_MAX_STABLE_VERSION);
if (isFaulty && !force) {
res.json({
success: false,
isFaulty,
isExperimental,
requiresConfirmation: true,
message: `Version ${version} has known bugs (v${CLIPROXY_FAULTY_RANGE.min.replace(/-\d+$/, '')}-${CLIPROXY_FAULTY_RANGE.max.replace(/-\d+$/, '')}). Set force=true to proceed.`,
});
return;
}
if (isExperimental && !force) {
res.json({
success: false,
isFaulty,
isExperimental,
requiresConfirmation: true,
message: `Version ${version} is experimental (above stable ${CLIPROXY_MAX_STABLE_VERSION.replace(/-\d+$/, '')}). Set force=true to proceed.`,
});
return;
}
const backend = getStoredConfiguredBackend();
const installResult = await installDashboardCliproxyVersion(version, backend);
res.json({ version, isFaulty, isExperimental, ...installResult });
} catch (error) {
logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' });
}
});
registerCliproxyRestartRoute(router);
export default router;
@@ -0,0 +1,70 @@
/**
* Shared internals for cliproxy-stats-routes: logger and rate-limit helpers.
*
* These are NOT part of the public barrel surface; they exist so the sibling
* submodules (quota helpers, version helpers, route handlers) can share the
* same logger instance and rate-limit state without re-declaring them.
*/
import type { Request } from 'express';
import { createLogger } from '../../../services/logging';
/**
* Shared logger for every cliproxy-stats submodule. Preserves the
* `web-server:routes:cliproxy-stats` channel used by P3 error conversions
* (`logger.error('stats.route.error', ...)`).
*/
export const logger = createLogger('web-server:routes:cliproxy-stats');
// ==================== Quota Rate Limiting ====================
export const QUOTA_RATE_LIMIT_WINDOW_MS = 60_000;
export const QUOTA_RATE_LIMIT_MAX_REQUESTS = 120;
export interface QuotaRateLimitEntry {
windowStart: number;
count: number;
}
/**
* In-memory rate limit state. Shared across all quota route handlers so that
* the same IP+provider key is throttled regardless of which handler runs.
*/
export const quotaRateLimits = new Map<string, QuotaRateLimitEntry>();
/**
* Build the rate-limit key for a request + provider pair.
*/
export function buildQuotaRateLimitKey(req: Request, provider: string): string {
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
return `${clientIp}:${provider}`;
}
/**
* Returns true when the caller should be rejected with 429.
* Evicts stale entries to prevent unbounded memory growth.
*/
export function isQuotaRouteRateLimited(req: Request, provider: string): boolean {
const key = buildQuotaRateLimitKey(req, provider);
const now = Date.now();
// Evict stale entries to prevent unbounded memory growth
if (quotaRateLimits.size > 1000) {
for (const [k, v] of quotaRateLimits) {
if (now - v.windowStart >= QUOTA_RATE_LIMIT_WINDOW_MS * 2) {
quotaRateLimits.delete(k);
}
}
}
const current = quotaRateLimits.get(key);
if (!current || now - current.windowStart >= QUOTA_RATE_LIMIT_WINDOW_MS) {
quotaRateLimits.set(key, { windowStart: now, count: 1 });
return false;
}
current.count += 1;
quotaRateLimits.set(key, current);
return current.count > QUOTA_RATE_LIMIT_MAX_REQUESTS;
}
@@ -0,0 +1,102 @@
/**
* CLIProxy version-check + versions-list payload resolvers.
*
* Public surface: `resolveCliproxyUpdateCheckPayload` and
* `resolveCliproxyVersionsPayload` are re-exported by the barrel.
*/
import {
checkCliproxyUpdate,
getInstalledCliproxyVersion,
getStoredConfiguredBackend,
} from '../../../cliproxy/binary-manager';
import { fetchAllVersions, isNewerVersion } from '../../../cliproxy/binary/version-checker';
import {
CLIPROXY_MAX_STABLE_VERSION,
CLIPROXY_FAULTY_RANGE,
} from '../../../cliproxy/binary/platform-detector';
type Backend = ReturnType<typeof getStoredConfiguredBackend>;
function buildUpdateCheckFallback(
backend: Backend,
getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion
) {
const currentVersion = getInstalledVersionFn(backend);
const isStable = !isNewerVersion(currentVersion, CLIPROXY_MAX_STABLE_VERSION);
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
return {
hasUpdate: false,
currentVersion,
latestVersion: currentVersion,
fromCache: true,
checkedAt: Date.now(),
backend,
backendLabel,
isStable,
maxStableVersion: CLIPROXY_MAX_STABLE_VERSION,
stabilityMessage: isStable
? undefined
: `v${currentVersion} has known stability issues. Max stable: v${CLIPROXY_MAX_STABLE_VERSION}`,
};
}
function buildVersionsFallback(
backend: Backend,
getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion
) {
const currentVersion = getInstalledVersionFn(backend);
return {
versions: currentVersion ? [currentVersion] : [],
latestStable: currentVersion || CLIPROXY_MAX_STABLE_VERSION,
latest: currentVersion || CLIPROXY_MAX_STABLE_VERSION,
fromCache: true,
checkedAt: Date.now(),
currentVersion,
maxStableVersion: CLIPROXY_MAX_STABLE_VERSION,
faultyRange: CLIPROXY_FAULTY_RANGE,
};
}
export interface ResolveUpdateCheckDeps {
checkCliproxyUpdateFn?: typeof checkCliproxyUpdate;
getInstalledVersionFn?: typeof getInstalledCliproxyVersion;
}
export interface ResolveVersionsDeps {
fetchAllVersionsFn?: typeof fetchAllVersions;
getInstalledVersionFn?: typeof getInstalledCliproxyVersion;
}
export async function resolveCliproxyUpdateCheckPayload(
backend: Backend,
deps: ResolveUpdateCheckDeps = {}
) {
const checkCliproxyUpdateFn = deps.checkCliproxyUpdateFn ?? checkCliproxyUpdate;
const getInstalledVersionFn = deps.getInstalledVersionFn ?? getInstalledCliproxyVersion;
return checkCliproxyUpdateFn(backend).catch(() =>
buildUpdateCheckFallback(backend, getInstalledVersionFn)
);
}
export async function resolveCliproxyVersionsPayload(
backend: Backend,
deps: ResolveVersionsDeps = {}
) {
const fetchAllVersionsFn = deps.fetchAllVersionsFn ?? fetchAllVersions;
const getInstalledVersionFn = deps.getInstalledVersionFn ?? getInstalledCliproxyVersion;
const result = await fetchAllVersionsFn(false, backend).catch(() => null);
if (!result) {
return buildVersionsFallback(backend, getInstalledVersionFn);
}
return {
...result,
currentVersion: getInstalledVersionFn(backend),
maxStableVersion: CLIPROXY_MAX_STABLE_VERSION,
faultyRange: CLIPROXY_FAULTY_RANGE,
};
}