fix(cliproxy): close second-round review gaps in pool onboarding and remote env

- create-command: show the pool onboarding hint only after profile
  creation fully succeeds; the pre-create placement burned the
  once-per-install dismissal when creation failed or rolled back
- remote env (claude): skip the read-level stale-pin filter once the
  migration marker exists - a post-migration pin is user-intentional
  (e.g. an explicit --config pick equal to a historical default)
- unified-config loader: document that loadOrCreateUnifiedConfig never
  writes to disk, so read paths on legacy installs stay side-effect free
This commit is contained in:
Tam Nhu Tran
2026-06-11 19:32:51 -04:00
parent cff9008f49
commit 7b8a6f5cce
5 changed files with 64 additions and 27 deletions
+10 -12
View File
@@ -189,18 +189,6 @@ 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 { try {
// Create instance directory // Create instance directory
console.log(info(`Creating profile: ${profileName}`)); console.log(info(`Creating profile: ${profileName}`));
@@ -326,6 +314,16 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
) )
); );
console.log(''); console.log('');
// Pool suggestion: shown only AFTER the profile creation fully
// succeeded (a pre-create hint would burn the once-per-install
// dismissal even when creation fails or rolls back). Print-only,
// TTY-gated, never blocks. Gated on hasUnifiedConfig() so legacy
// profiles.json-only installs receive the hint from ccs doctor only
// (where dismissal semantics are preserved). The profile now exists,
// so the registry count is already post-create (no +1 needed).
if (!profileExistedBeforeCreate && hasUnifiedConfig()) {
maybeShowPoolOnboardingHint(countNativeClaudeProfiles());
}
process.exit(0); process.exit(0);
} else { } else {
await rollbackFailedCreate(); await rollbackFailedCreate();
@@ -320,25 +320,23 @@ describe('Phase 5: Pool Onboarding Hint', () => {
}); });
// ── 11. Create-command suggestion: hint fires with post-create count ────── // ── 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 () => { it('hint fires with count 2 after a successful 2nd-profile create (post-create call site)', async () => {
// NOTE: SIMULATION of the create-command call site, not the real command. // NOTE: SIMULATION of the create-command call site, not the real command.
// It replicates the threshold logic (existingCount + 1) and calls the same // create-command shows the hint only AFTER profile creation succeeds (a
// exported hint symbol; it does not invoke create-command end to end. // pre-create hint would burn the once-per-install dismissal on a failed
// 1 existing profile in the registry, simulating pre-create state. // create). At that point the registry already contains the new profile,
// create-command calls maybeShowPoolOnboardingHint(existingCount + 1) = hint(2). // so the call is maybeShowPoolOnboardingHint(countNativeClaudeProfiles()).
const ccsDir = path.join(tempHome, '.ccs'); const ccsDir = path.join(tempHome, '.ccs');
writeProfiles(ccsDir, ['work']); writeProfiles(ccsDir, ['work', 'personal']); // post-create state: 2 profiles
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {}); const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try { try {
const { maybeShowPoolOnboardingHint, countNativeClaudeProfiles } = await import( const { maybeShowPoolOnboardingHint, countNativeClaudeProfiles } = await import(
`../routing/pool-onboarding-hint?p5create=${Date.now()}` `../routing/pool-onboarding-hint?p5create=${Date.now()}`
); );
// Replicate the threshold logic from create-command.ts lines 195-200 const count = countNativeClaudeProfiles();
const existingCount = countNativeClaudeProfiles(); expect(count).toBe(2);
expect(existingCount).toBe(1); const result = maybeShowPoolOnboardingHint(count);
// existingCount >= 1 so the hint is called with existingCount + 1
const result = maybeShowPoolOnboardingHint(existingCount + 1);
expect(result.printed).toBe(true); expect(result.printed).toBe(true);
const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n'); const allOutput = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(allOutput).toContain('2 Claude profiles'); expect(allOutput).toContain('2 Claude profiles');
@@ -550,4 +550,38 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
// Priority 1 untouched: the explicit pin survives. // Priority 1 untouched: the explicit pin survives.
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
}); });
it('getRemoteEnvVars (claude): migration marker set means pins are user-intentional and NOT filtered', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
// Marker present: the file was already cleaned once, so any pin that
// exists now was put there deliberately (e.g. ccs claude --config picked
// a value that happens to equal a historical default).
const markerDir = path.join(ccsDir, 'cliproxy');
fs.mkdirSync(markerDir, { recursive: true });
fs.writeFileSync(path.join(markerDir, '.claude-model-migrated'), new Date().toISOString());
const settingsPath = path.join(ccsDir, 'claude.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-6', // equals a stale default, but post-migration = explicit
},
}),
'utf-8'
);
const env = getRemoteEnvVars('claude', {
host: 'example.com',
port: 8317,
protocol: 'http',
});
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
});
}); });
+8 -4
View File
@@ -808,10 +808,14 @@ export function getRemoteEnvVars(
// never rewrites the file, so a remote-only user whose settings still // never rewrites the file, so a remote-only user whose settings still
// carry an old auto-written default would stay pinned. Filter stale // carry an old auto-written default would stay pinned. Filter stale
// defaults at read level so values equal to a historical default are // defaults at read level so values equal to a historical default are
// dropped while user-custom pins survive. Priority 1 (explicit custom // dropped while user-custom pins survive. Once the migration marker
// settings path) is intentionally left untouched: an explicitly passed // exists the file has already been cleaned, so any pin present after
// settings file is the user's deliberate choice. // that is user-intentional (e.g. an explicit `ccs claude --config`
if (provider === 'claude') { // pick that happens to equal a historical default) and must NOT be
// filtered. Priority 1 (explicit custom settings path) is
// intentionally left untouched: an explicitly passed settings file
// is the user's deliberate choice.
if (provider === 'claude' && !claudeModelMigrationDone()) {
userEnvVars = filterClaudeStaleModelPins(userEnvVars); userEnvVars = filterClaudeStaleModelPins(userEnvVars);
} }
} }
+3
View File
@@ -191,6 +191,9 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
* Merges with defaults to ensure all sections exist. * Merges with defaults to ensure all sections exist.
*/ */
export function loadOrCreateUnifiedConfig(): UnifiedConfig { export function loadOrCreateUnifiedConfig(): UnifiedConfig {
// Read-only: "create" means an in-memory default object when config.yaml is
// absent. This never writes to disk, so callers on legacy installs can use
// it for read paths without implicitly creating config.yaml.
const existing = loadUnifiedConfig(); const existing = loadUnifiedConfig();
if (existing) { if (existing) {
const merged = mergeWithDefaults(existing); const merged = mergeWithDefaults(existing);