diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index b0cd4aad..262dc838 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -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}`)); diff --git a/src/cliproxy/__tests__/pool-onboarding-phase5.test.ts b/src/cliproxy/__tests__/pool-onboarding-phase5.test.ts new file mode 100644 index 00000000..2bf32d5c --- /dev/null +++ b/src/cliproxy/__tests__/pool-onboarding-phase5.test.ts @@ -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 = {}; + 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 = { + 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 } }) => { + 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 } }) => { + 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(); + } + }); +}); diff --git a/src/cliproxy/routing/pool-onboarding-hint.ts b/src/cliproxy/routing/pool-onboarding-hint.ts new file mode 100644 index 00000000..88d5c71e --- /dev/null +++ b/src/cliproxy/routing/pool-onboarding-hint.ts @@ -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' }; + } +} diff --git a/src/config/schemas/cliproxy.ts b/src/config/schemas/cliproxy.ts index c5a9d5d3..dfc50bd7 100644 --- a/src/config/schemas/cliproxy.ts +++ b/src/config/schemas/cliproxy.ts @@ -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; } /** diff --git a/src/dispatcher/flows/account-flow.ts b/src/dispatcher/flows/account-flow.ts index bf67bc80..e5189e9f 100644 --- a/src/dispatcher/flows/account-flow.ts +++ b/src/dispatcher/flows/account-flow.ts @@ -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 { const { @@ -56,6 +58,15 @@ export async function runAccountFlow(ctx: ProfileDispatchContext): Promise 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', diff --git a/src/management/doctor.ts b/src/management/doctor.ts index e7cace57..13d0fa3a 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -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(''); }