Merge pull request #1512 from kaitranntt/kai/feat/1464-onboarding-hints

feat(cliproxy): pool onboarding hints + credential-import verdict (phase 5 of #1464)
This commit is contained in:
Kai (Tam Nhu) Tran
2026-06-11 12:16:11 -04:00
committed by GitHub
6 changed files with 732 additions and 1 deletions
+17 -1
View File
@@ -29,7 +29,11 @@ import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types';
import { stripAmbientProviderCredentials } from './create-command-env';
import { isUnifiedMode } from '../../config/config-loader-facade';
import { isUnifiedMode, hasUnifiedConfig } from '../../config/config-loader-facade';
import {
maybeShowPoolOnboardingHint,
countNativeClaudeProfiles,
} from '../../cliproxy/routing/pool-onboarding-hint';
function sanitizeProfileNameForInstance(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
@@ -185,6 +189,18 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
}
};
// Pool suggestion: when creating a 2nd+ native Claude profile, show the
// once-per-install onboarding hint. Print-only, TTY-gated, never blocks.
// Pass existingCount + 1 so the hint sees the post-create count (>= 2).
// Gated on hasUnifiedConfig() so legacy profiles.json-only installs receive
// the hint from ccs doctor only (where dismissal semantics are preserved).
if (!profileExistedBeforeCreate && hasUnifiedConfig()) {
const existingCount = countNativeClaudeProfiles();
if (existingCount >= 1) {
maybeShowPoolOnboardingHint(existingCount + 1);
}
}
try {
// Create instance directory
console.log(info(`Creating profile: ${profileName}`));
@@ -0,0 +1,502 @@
/**
* Phase 5: Pool Onboarding Hint - Test Suite
*
* Covers:
* 1. Non-TTY: hint is silent
* 2. Pool already enabled: hint is silent
* 3. Dismissed flag: hint is silent
* 4. Fewer than 2 profiles: hint is silent
* 5. Exactly 2 profiles, TTY, not dismissed, pool off: hint prints (single [i] marker)
* 6. After dismiss, second call is silent
* 7. Pre-computed count avoids double registry read
* 8. isOnboardingHintDismissed / dismissOnboardingHint roundtrip
* 9. countNativeClaudeProfiles returns only account-type profiles
* 10. Doctor site (simulation): maybeShowPoolOnboardingHint callable after report phase
* 10b. Doctor wiring (static): doctor.ts imports and calls the hint symbol
* 11. Create-command suggestion: hint fires with count=2 when 1 existing profile present
* 12. Legacy-only install: dismissOnboardingHint does not create config.yaml
* 13. Hint copy includes 'ccs claude' entry point and quota trade-off language
* 14. Legacy-only install: hasUnifiedConfig() returns false, enforcing call-site gate
* 15. Malformed config (with count): hint swallows the error, never throws, prints nothing
* 16. Malformed config (no count, doctor path): hint never throws
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
// Non-cache-busted facade import so mutateConfig targets the SHARED singleton
import { invalidateConfigCache as invalidateSharedConfigCache } from '../../config/config-loader-facade';
// ── Helpers ─────────────────────────────────────────────────────────────────
function createTestHome(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-onboarding-test-'));
const ccsDir = path.join(dir, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 1\n', 'utf8');
return dir;
}
/** Create a legacy-only home: has profiles.json but NO config.yaml */
function createLegacyTestHome(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-pool-onboarding-legacy-'));
const ccsDir = path.join(dir, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
// Intentionally no config.yaml - legacy install
return dir;
}
/** Write a minimal profiles.json with N account-type entries */
function writeProfiles(ccsDir: string, names: string[]): void {
const profiles: Record<string, unknown> = {};
for (const n of names) {
profiles[n] = { type: 'account', created: new Date().toISOString(), last_used: null };
}
fs.writeFileSync(
path.join(ccsDir, 'profiles.json'),
JSON.stringify({ version: '2.0.0', profiles, default: null }, null, 2),
'utf8'
);
}
/** Write a minimal profiles.json with mixed types */
function writeProfilesMixed(ccsDir: string): void {
const profiles: Record<string, unknown> = {
acct1: { type: 'account', created: new Date().toISOString(), last_used: null },
settings1: { type: 'settings', created: new Date().toISOString(), last_used: null },
};
fs.writeFileSync(
path.join(ccsDir, 'profiles.json'),
JSON.stringify({ version: '2.0.0', profiles, default: null }, null, 2),
'utf8'
);
}
// ── Tests ───────────────────────────────────────────────────────────────────
describe('Phase 5: Pool Onboarding Hint', () => {
let tempHome: string;
let originalCcsHome: string | undefined;
let originalIsTTY: boolean | undefined;
beforeEach(() => {
tempHome = createTestHome();
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
// Default: treat stdout as TTY for hint to fire
originalIsTTY = process.stdout.isTTY;
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
Object.defineProperty(process.stdout, 'isTTY', {
value: originalIsTTY,
configurable: true,
});
fs.rmSync(tempHome, { recursive: true, force: true });
invalidateSharedConfigCache();
});
// ── 1. Non-TTY ──────────────────────────────────────────────────────────
it('returns skipReason non-tty when stdout.isTTY is false', async () => {
Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5nontty=${Date.now()}`
);
const result = maybeShowPoolOnboardingHint();
expect(result.printed).toBe(false);
expect(result.skipReason).toBe('non-tty');
});
// ── 2. Pool already enabled ─────────────────────────────────────────────
it('returns skipReason pool-already-enabled when pool routing is on', async () => {
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
// Enable pool routing first
const { mutateConfig } = await import(`../../config/config-loader-facade?p5pool=${Date.now()}`);
mutateConfig((cfg: { cliproxy?: { pool_routing?: Record<string, unknown> } }) => {
if (!cfg.cliproxy) return;
cfg.cliproxy.pool_routing = { ...(cfg.cliproxy.pool_routing ?? {}), enabled: true };
});
invalidateSharedConfigCache();
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5pool2=${Date.now()}`
);
const result = maybeShowPoolOnboardingHint();
expect(result.printed).toBe(false);
expect(result.skipReason).toBe('pool-already-enabled');
});
// ── 3. Dismissed ────────────────────────────────────────────────────────
it('returns skipReason dismissed when onboarding_hint_dismissed is true', async () => {
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
const { mutateConfig } = await import(`../../config/config-loader-facade?p5dis=${Date.now()}`);
mutateConfig((cfg: { cliproxy?: { pool_routing?: Record<string, unknown> } }) => {
if (!cfg.cliproxy) return;
cfg.cliproxy.pool_routing = {
...(cfg.cliproxy.pool_routing ?? {}),
onboarding_hint_dismissed: true,
};
});
invalidateSharedConfigCache();
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5dis2=${Date.now()}`
);
const result = maybeShowPoolOnboardingHint();
expect(result.printed).toBe(false);
expect(result.skipReason).toBe('dismissed');
});
// ── 4. Fewer than 2 profiles ────────────────────────────────────────────
it('returns skipReason fewer-than-2-profiles when only 1 profile exists', async () => {
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work']);
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5few=${Date.now()}`
);
const result = maybeShowPoolOnboardingHint();
expect(result.printed).toBe(false);
expect(result.skipReason).toBe('fewer-than-2-profiles');
});
it('returns skipReason fewer-than-2-profiles when no profiles exist', async () => {
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5zero=${Date.now()}`
);
const result = maybeShowPoolOnboardingHint();
expect(result.printed).toBe(false);
expect(result.skipReason).toBe('fewer-than-2-profiles');
});
// ── 5. Hint prints when 2 profiles, TTY, not dismissed, pool off ────────
it('prints hint and returns printed=true when conditions are met', async () => {
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5print=${Date.now()}`
);
const result = maybeShowPoolOnboardingHint();
expect(result.printed).toBe(true);
expect(result.skipReason).toBeUndefined();
// Verify output: exactly one [i] marker (no double-prefix from info() + literal)
expect(consoleSpy.mock.calls.length).toBeGreaterThan(0);
const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(allOutput).toContain('2 Claude profiles');
const iMarkerCount = (allOutput.match(/\[i\]/g) ?? []).length;
expect(iMarkerCount).toBe(1);
} finally {
consoleSpy.mockRestore();
}
});
// ── 6. After dismiss, second call is silent ─────────────────────────────
it('is silent on the second call because hint auto-dismisses after first print', async () => {
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
// Use the same module instance for both calls so the dismissed flag persists
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5once=${Date.now()}`
);
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const first = maybeShowPoolOnboardingHint();
expect(first.printed).toBe(true);
invalidateSharedConfigCache();
const second = maybeShowPoolOnboardingHint();
expect(second.printed).toBe(false);
expect(second.skipReason).toBe('dismissed');
} finally {
consoleSpy.mockRestore();
}
});
// ── 7. Pre-computed count skips internal registry read ─────────────────
it('uses the pre-computed count when provided', async () => {
// No profiles.json — but we pass count=3 directly so it should still print
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5precount=${Date.now()}`
);
const result = maybeShowPoolOnboardingHint(3);
expect(result.printed).toBe(true);
} finally {
consoleSpy.mockRestore();
}
});
// ── 8. isOnboardingHintDismissed / dismissOnboardingHint roundtrip ─────
it('dismissOnboardingHint persists and isOnboardingHintDismissed reads it', async () => {
const { isOnboardingHintDismissed, dismissOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5dismiss=${Date.now()}`
);
expect(isOnboardingHintDismissed()).toBe(false);
dismissOnboardingHint();
invalidateSharedConfigCache();
expect(isOnboardingHintDismissed()).toBe(true);
});
// ── 9. countNativeClaudeProfiles counts only account-type profiles ───────
it('countNativeClaudeProfiles ignores non-account type profiles', async () => {
const ccsDir = path.join(tempHome, '.ccs');
writeProfilesMixed(ccsDir); // 1 account + 1 settings
const { countNativeClaudeProfiles } = await import(
`../routing/pool-onboarding-hint?p5count=${Date.now()}`
);
expect(countNativeClaudeProfiles()).toBe(1);
});
it('countNativeClaudeProfiles returns 0 when no profiles.json exists', async () => {
const { countNativeClaudeProfiles } = await import(
`../routing/pool-onboarding-hint?p5count2=${Date.now()}`
);
expect(countNativeClaudeProfiles()).toBe(0);
});
// ── 10. Doctor site: maybeShowPoolOnboardingHint is exported and callable ─
it('maybeShowPoolOnboardingHint is callable after the doctor report phase', async () => {
// NOTE: This is a SIMULATION of the doctor post-checks call site, not the
// real Doctor pipeline. The hint fires from Doctor's private displayResults()
// method, which has no cheap standalone entry point - exercising it directly
// would require constructing Doctor and running the full async check pipeline
// (system / OAuth / CLIProxy / Docker), which is heavy and environment-fragile.
// Instead we call the exact exported symbol the doctor imports
// (maybeShowPoolOnboardingHint, see doctor.ts import) under the same
// preconditions: 2 profiles, TTY, not dismissed. The wiring itself - that
// doctor.ts imports this symbol - is asserted in the companion test below.
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5doc=${Date.now()}`
);
// doctor calls maybeShowPoolOnboardingHint() with no pre-computed count
const result = maybeShowPoolOnboardingHint();
expect(result.printed).toBe(true);
const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(allOutput).toContain('2 Claude profiles');
} finally {
consoleSpy.mockRestore();
}
});
// ── 10b. Doctor wiring: doctor.ts imports and calls the hint symbol ───────
it('doctor.ts wires maybeShowPoolOnboardingHint as the doctor hint site', () => {
// Cheap static wiring assertion: confirms the real doctor call site imports
// and invokes the same symbol the simulation above exercises, without
// executing the heavy Doctor pipeline. If the doctor stops importing or
// calling the hint, this fails - catching a broken hint site that a pure
// simulation test could not.
const doctorSrc = fs.readFileSync(
path.join(__dirname, '..', '..', 'management', 'doctor.ts'),
'utf8'
);
expect(doctorSrc).toContain(
"import { maybeShowPoolOnboardingHint } from '../cliproxy/routing/pool-onboarding-hint'"
);
expect(doctorSrc).toContain('maybeShowPoolOnboardingHint();');
});
// ── 11. Create-command suggestion: hint fires with post-create count ──────
it('hint fires with count 2 when create-command passes existingCount+1 for 1 existing profile', async () => {
// NOTE: SIMULATION of the create-command call site, not the real command.
// It replicates the threshold logic (existingCount + 1) and calls the same
// exported hint symbol; it does not invoke create-command end to end.
// 1 existing profile in the registry, simulating pre-create state.
// create-command calls maybeShowPoolOnboardingHint(existingCount + 1) = hint(2).
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work']);
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const { maybeShowPoolOnboardingHint, countNativeClaudeProfiles } = await import(
`../routing/pool-onboarding-hint?p5create=${Date.now()}`
);
// Replicate the threshold logic from create-command.ts lines 195-200
const existingCount = countNativeClaudeProfiles();
expect(existingCount).toBe(1);
// existingCount >= 1 so the hint is called with existingCount + 1
const result = maybeShowPoolOnboardingHint(existingCount + 1);
expect(result.printed).toBe(true);
const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(allOutput).toContain('2 Claude profiles');
} finally {
consoleSpy.mockRestore();
}
});
// ── 12. Legacy-only install: dismissal does not create config.yaml ────────
it('dismissOnboardingHint does not create config.yaml for legacy profiles.json-only installs', async () => {
// Override tempHome with a legacy-only home (no config.yaml)
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
const legacyHome = createLegacyTestHome();
process.env.CCS_HOME = legacyHome;
invalidateSharedConfigCache();
try {
const ccsDir = path.join(legacyHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
const configYamlPath = path.join(ccsDir, 'config.yaml');
const { dismissOnboardingHint, isOnboardingHintDismissed } = await import(
`../routing/pool-onboarding-hint?p5legacy=${Date.now()}`
);
// Sanity: config.yaml does not exist yet
expect(fs.existsSync(configYamlPath)).toBe(false);
// isOnboardingHintDismissed returns false (reads empty config, no persist)
expect(isOnboardingHintDismissed()).toBe(false);
// dismissOnboardingHint must NOT create config.yaml
dismissOnboardingHint();
expect(fs.existsSync(configYamlPath)).toBe(false);
} finally {
fs.rmSync(legacyHome, { recursive: true, force: true });
invalidateSharedConfigCache();
}
});
// ── 13. Hint copy includes 'ccs claude' and quota trade-off language ──────
it("hint copy mentions 'ccs claude' and quota trade-off", async () => {
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5copy=${Date.now()}`
);
const result = maybeShowPoolOnboardingHint();
expect(result.printed).toBe(true);
const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(allOutput).toContain('ccs claude');
expect(allOutput).toContain('share quota');
} finally {
consoleSpy.mockRestore();
}
});
// ── 14. Legacy-only install: hasUnifiedConfig() is false at account-flow / create-command sites ──
it('hasUnifiedConfig returns false for legacy profiles.json-only installs (call-site gate)', async () => {
// Verifies that the guard used in account-flow.ts and create-command.ts
// (hasUnifiedConfig()) correctly reports false for a profiles.json-only home,
// meaning those call sites silently skip the hint and legacy users receive
// the hint exclusively from ccs doctor.
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
const legacyHome = createLegacyTestHome();
process.env.CCS_HOME = legacyHome;
invalidateSharedConfigCache();
try {
const ccsDir = path.join(legacyHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
const { hasUnifiedConfig } = await import(
`../../config/config-loader-facade?p5gate=${Date.now()}`
);
// The call-site guard must return false - legacy install has no config.yaml
expect(hasUnifiedConfig()).toBe(false);
} finally {
fs.rmSync(legacyHome, { recursive: true, force: true });
invalidateSharedConfigCache();
}
});
// ── 15. Malformed config: hint never throws and prints nothing ────────────
it('does not throw or print when config.yaml is malformed (hint must not break launch)', async () => {
// A pre-computed profileCount (passed by the launch / create call sites)
// clears the cheap gates, so the decision reaches the single config load.
// A malformed config.yaml makes loadOrCreateUnifiedConfig throw; the
// try/catch in maybeShowPoolOnboardingHint must swallow it - returning
// printed=false with skipReason 'error' and emitting no hint - so an account
// launch is never broken by a bad config.
const ccsDir = path.join(tempHome, '.ccs');
// Overwrite the valid config.yaml from createTestHome() with invalid YAML.
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'cliproxy: [unclosed\n', 'utf8');
invalidateSharedConfigCache();
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5malformed=${Date.now()}`
);
let result: { printed: boolean; skipReason?: string } | undefined;
// Must not throw. Pass count=2 to bypass the registry (which itself loads
// config) and drive the decision into the explicit config load + try/catch.
expect(() => {
result = maybeShowPoolOnboardingHint(2);
}).not.toThrow();
expect(result?.printed).toBe(false);
expect(result?.skipReason).toBe('error');
// No hint line printed.
const hintLines = consoleSpy.mock.calls
.map((c) => String(c[0]))
.filter((s) => s.includes('Claude profiles'));
expect(hintLines.length).toBe(0);
} finally {
consoleSpy.mockRestore();
}
});
// ── 16. Malformed config via doctor path (no count): still no throw ───────
it('does not throw when config is malformed and no profileCount is passed (doctor path)', async () => {
// The doctor site calls maybeShowPoolOnboardingHint() with no count. With a
// malformed config, countNativeClaudeProfiles() swallows the registry error
// and returns 0, short-circuiting at fewer-than-2-profiles. Either way the
// call must never throw - this guards the doctor call site specifically.
const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work', 'personal']);
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'cliproxy: [unclosed\n', 'utf8');
invalidateSharedConfigCache();
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const { maybeShowPoolOnboardingHint } = await import(
`../routing/pool-onboarding-hint?p5malformed2=${Date.now()}`
);
let result: { printed: boolean } | undefined;
expect(() => {
result = maybeShowPoolOnboardingHint();
}).not.toThrow();
expect(result?.printed).toBe(false);
const hintLines = consoleSpy.mock.calls
.map((c) => String(c[0]))
.filter((s) => s.includes('Claude profiles'));
expect(hintLines.length).toBe(0);
} finally {
consoleSpy.mockRestore();
}
});
});
@@ -0,0 +1,185 @@
/**
* Pool onboarding hint
*
* Fires once (per install, TTY only) when a user has >= 2 native Claude account
* profiles and has not yet enabled pool routing. Covers the #1464 use case:
* existing users with multiple native profiles who have never touched CLIProxy
* and would miss the 1->2 account-add opt-in prompt.
*
* Three hint sites call this module:
* 1. ccs doctor (post-checks summary, fires for ALL install types)
* 2. account-flow launch (print-only, pre-spawn) - unified config only
* 3. ccs auth create (before spawning Claude for a 2nd+ profile) - unified config only
*
* Sites 2 and 3 are gated on hasUnifiedConfig() so that legacy profiles.json-only
* installs receive the hint exclusively from ccs doctor (site 1). This ensures
* once-per-install dismissal semantics for every install type: unified installs
* dismiss via config.yaml on first show; legacy installs see the hint from
* doctor only (which is the natural discovery surface for that population).
*
* Dismissal: stored in pool_routing.onboarding_hint_dismissed in config.yaml.
* Reuses the Phase 3 pool_routing schema family - one schema home, no duplicate
* plumbing. Legacy installs cannot persist a dismissal without config.yaml;
* this is intentional - dismissal for legacy users happens via ccs migrate or
* pool enable (both create config.yaml).
*
* TTY contract: non-TTY sessions never print the hint (single line only).
* The hint is informational only - it never blocks the calling flow.
*
* History note: pooled sessions live in the CLIProxy lane, not the native
* profile's CLAUDE_CONFIG_DIR lane. Transcripts and --continue inventory do
* NOT follow. This is stated on the docs page linked in the hint copy.
*/
import { info } from '../../utils/ui';
import {
loadOrCreateUnifiedConfig,
mutateConfig,
hasUnifiedConfig,
} from '../../config/config-loader-facade';
import type { UnifiedConfig } from '../../config/unified-config-types';
import { ProfileRegistry } from '../../auth/profile-registry';
// Docs anchor - Phase 7 acceptance item: verify curl returns HTTP 200 before release.
// Pattern matches canonical docs domain used in error-codes.ts and version-command.ts.
export const POOL_DOCS_LINK = 'https://docs.ccs.kaitran.ca/features/pool';
/**
* Whether the one-time pool onboarding hint has already been dismissed.
*
* @param config - Optional pre-loaded config to avoid a redundant disk read +
* YAML parse. When omitted the config is loaded here.
*/
export function isOnboardingHintDismissed(config?: UnifiedConfig): boolean {
const cfg = config ?? loadOrCreateUnifiedConfig();
return cfg.cliproxy?.pool_routing?.onboarding_hint_dismissed === true;
}
/**
* Mark the pool onboarding hint as permanently dismissed.
* Written after first display so subsequent invocations are silent.
*
* Guard: skipped when config.yaml does not yet exist (legacy profiles.json-only
* installs). Writing here would create config.yaml, flipping isUnifiedMode()
* and silently mode-migrating the install outside the deliberate ccs migrate
* flow. For those users the hint may re-appear across process restarts; that
* is acceptable -- the hint is informational and non-blocking, and the user
* can silence it permanently via ccs migrate.
*/
export function dismissOnboardingHint(): void {
if (!hasUnifiedConfig()) {
// Legacy-only install: skip persist to avoid implicit config.yaml creation.
return;
}
mutateConfig((cfg) => {
if (!cfg.cliproxy) return;
cfg.cliproxy.pool_routing = {
...cfg.cliproxy.pool_routing,
onboarding_hint_dismissed: true,
};
});
}
/**
* Whether pool routing is currently enabled.
*
* @param config - Optional pre-loaded config to avoid a redundant disk read +
* YAML parse. When omitted the config is loaded here.
*/
function isPoolEnabled(config?: UnifiedConfig): boolean {
const cfg = config ?? loadOrCreateUnifiedConfig();
return cfg.cliproxy?.pool_routing?.enabled === true;
}
/**
* Count native Claude account profiles (type === 'account').
*
* Uses ProfileRegistry so the count respects both legacy profiles.json and
* unified config accounts - same source of truth as the rest of the codebase.
*/
export function countNativeClaudeProfiles(): number {
try {
const registry = new ProfileRegistry();
const profiles = registry.getAllProfilesMerged();
return Object.values(profiles).filter((p) => p.type === 'account').length;
} catch {
return 0;
}
}
export interface OnboardingHintResult {
/** Whether the hint was printed */
printed: boolean;
/** Why the hint was skipped (only set when printed === false) */
skipReason?: string;
}
/**
* Maybe print the one-time pool onboarding hint.
*
* Skips silently when any of the following are true:
* - Not a TTY (piped / CI / non-interactive)
* - Fewer than 2 native Claude account profiles exist
* - Pool routing is already enabled
* - The hint was already dismissed
* - Any error occurs while deciding (e.g. malformed config.yaml)
*
* Checks run cheapest-first: TTY and profile count are evaluated before any
* config read, and the config is loaded exactly once for the two
* config-derived checks. The entire decision is wrapped in try/catch so a
* hint failure can never break an account launch.
*
* When the hint is printed for the first time it is also dismissed so it
* never appears again (it is informational, not an interactive prompt).
*
* @param profileCount - Pre-computed profile count (pass undefined to compute
* it here). The caller may pass a count it already has to avoid a second
* registry read.
*/
export function maybeShowPoolOnboardingHint(profileCount?: number): OnboardingHintResult {
// A hint must never break a launch. Any error in the decision path
// (malformed config.yaml, registry read failure, etc.) silently skips the
// hint rather than rethrowing into the caller's account-launch flow.
try {
// Cheapest checks first, no disk read required.
// Non-TTY: stay silent (piped / CI / non-interactive).
if (!process.stdout.isTTY) {
return { printed: false, skipReason: 'non-tty' };
}
const count = profileCount ?? countNativeClaudeProfiles();
if (count < 2) {
return { printed: false, skipReason: 'fewer-than-2-profiles' };
}
// Load config once and reuse it for both config-derived checks below,
// avoiding two disk reads + YAML parses per call.
const config = loadOrCreateUnifiedConfig();
if (isPoolEnabled(config)) {
return { printed: false, skipReason: 'pool-already-enabled' };
}
if (isOnboardingHintDismissed(config)) {
return { printed: false, skipReason: 'dismissed' };
}
console.log(
info(
`You have ${count} Claude profiles. 'ccs claude' pool auto-continues on limits (accounts share quota). Docs: ${POOL_DOCS_LINK}`
)
);
// Dismiss so subsequent invocations (other sites or next run) are silent.
try {
dismissOnboardingHint();
} catch {
// Best-effort: a config write failure must not surface to the user here.
}
return { printed: true };
} catch {
// Decision failed (e.g. malformed config) - skip the hint, never throw.
return { printed: false, skipReason: 'error' };
}
}
+6
View File
@@ -150,6 +150,12 @@ export interface CLIProxyPoolRoutingConfig {
* Prevents re-prompting after an explicit decline.
*/
prompt_dismissed?: boolean;
/**
* Whether the user has dismissed the pool onboarding hint.
* Fires once when >= 2 native Claude profiles exist and no pool is enabled.
* Shares the pool_routing key family - one schema home, no duplicate plumbing.
*/
onboarding_hint_dismissed?: boolean;
}
/**
+11
View File
@@ -10,6 +10,8 @@ import { maybeWarnAboutResumeLaneMismatch } from '../../auth/resume-lane-warning
import { isProfileLocalSharedResourceMode } from '../../auth/shared-resource-policy';
import { resolveNativeClaudeLaunchArgs } from '../environment-builder';
import type { ProfileDispatchContext } from '../dispatcher-context';
import { maybeShowPoolOnboardingHint } from '../../cliproxy/routing/pool-onboarding-hint';
import { hasUnifiedConfig } from '../../config/config-loader-facade';
export async function runAccountFlow(ctx: ProfileDispatchContext): Promise<void> {
const {
@@ -56,6 +58,15 @@ export async function runAccountFlow(ctx: ProfileDispatchContext): Promise<void>
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
await maybeWarnAboutResumeLaneMismatch(profileInfo.name, instancePath, nativeClaudeRemainingArgs);
// One-time pool onboarding hint: fires when >= 2 native Claude profiles exist
// and pool routing is not yet enabled. Print-only, TTY-gated, never blocks spawn.
// Gated on hasUnifiedConfig() so legacy profiles.json-only installs receive the
// hint from ccs doctor only (where dismissal semantics are preserved).
if (hasUnifiedConfig()) {
maybeShowPoolOnboardingHint();
}
const launchArgs = resolveNativeClaudeLaunchArgs(
nativeClaudeRemainingArgs,
'account',
+11
View File
@@ -17,6 +17,7 @@ import {
} from './checks';
import { runAutoRepair } from './repair';
import { getDockerKeyRotationStatus } from '../docker/docker-key-rotation';
import { maybeShowPoolOnboardingHint } from '../cliproxy/routing/pool-onboarding-hint';
/**
* Doctor Class - Orchestrates health checks
@@ -157,6 +158,16 @@ class Doctor {
console.log(info(`Tip: Use ${color('ccs config', 'command')} for web-based configuration`));
}
// Pool onboarding hint: fires when >= 2 native Claude profiles exist and
// pool routing is not yet enabled. TTY-gated and never blocks (failures are
// swallowed inside maybeShowPoolOnboardingHint). This is the deliberately
// ungated discovery surface for legacy profiles.json-only installs, which
// have no config.yaml to persist dismissal: those users may see the hint on
// every doctor run until they run `ccs migrate` or enable pool routing.
// Unified installs persist dismissal in config.yaml, so for them it is
// genuinely once per install.
maybeShowPoolOnboardingHint();
console.log('');
}