mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 16:16:52 +00:00
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:
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user