mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 08:18:23 +00:00
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:
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user