feat(custom-model): skip Claude Code's first-run wizard on custom-model launches

A fresh, isolated CLAUDE_CONFIG_DIR (used to keep an injected API key
away from a stored claude.ai OAuth login) looks like a brand-new Claude
Code profile to the CLI, so it replays its ENTIRE first-run sequence on
every single launch: the theme picker, the security-notes screen, the
per-project "trust this folder?" dialog, and (running with
--dangerously-skip-permissions) a one-time bypass-permissions warning —
confirmed live, none of which a real, already-onboarded profile shows
again.

- New registry-declared env-kind field `skipFirstRunPrompts` (alongside
  apiKeyTrustFile, which it reuses) — claude's entry only, carried
  through buildCustomModelInjection (pure) into
  applyCustomModelInjection (IO).
- seedFirstRunOnboardingState(): merges hasCompletedOnboarding: true and
  this session's own projects[workingDir].hasTrustDialogAccepted: true
  into the same <configDir>/.claude.json the API-key trust file already
  writes to — other projects and other fields on this session's own
  entry are left untouched.
- seedSkipBypassPermissionsPrompt(): merges
  skipDangerousModePermissionPrompt: true into <configDir>/settings.json,
  a separate file, same corrupt-tolerant merge behavior.
- applyCustomModelInjection() gains an optional workingDir parameter,
  threaded from session.workingDir (dedicated apply route) /
  resolvedCasePath (quick-start route) — boot recovery omits it
  (a dialog already answered once needs no re-seed on the same,
  persisted isolated directory).

Tests added at the pure-builder, IO-wrapper (including merge-preserves-
other-fields and corrupt-file-tolerance cases), and existing directory-
listing assertions updated for the new settings.json file. Typecheck/
lint/format clean; full suite shows no new regressions (baseline
pre-existing Windows-environment failures unchanged, 8 more passing
tests than before — the ones added here).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
This commit is contained in:
Devvyn
2026-09-17 09:21:36 +08:00
co-authored by Claude Sonnet 5
parent 2c89359d42
commit 211b872335
11 changed files with 272 additions and 6 deletions
+6
View File
@@ -358,6 +358,12 @@ const capabilitiesSchema = z
.object({ relPath: z.string().min(1).max(80), shape: z.literal('claude-api-key-responses') })
.strict()
.optional(),
// An isolated config directory replays the CLI's whole first-run sequence (theme
// picker, security notes, per-project trust dialog, bypass-permissions warning)
// on every launch, same root cause as apiKeyTrustFile above — this reuses that
// same file to pre-seed the state a real, already-onboarded profile carries. See
// the customModelInjection doc comment in cli-registry/types.ts.
skipFirstRunPrompts: z.boolean().optional(),
})
.strict(),
z
+8
View File
@@ -271,6 +271,14 @@ const CLAUDE: CliEntry = {
// Pre-seeding this file's customApiKeyResponses.approved list (verified against a real
// ~/.claude.json after answering the prompt once by hand) answers it in advance instead.
apiKeyTrustFile: { relPath: '.claude.json', shape: 'claude-api-key-responses' },
// ⚠️ Same isolated-directory root cause, one step further: verified live that on top
// of the API-key prompt above, a fresh CLAUDE_CONFIG_DIR also replays claude's ENTIRE
// first-run sequence on every launch — the theme picker, the security-notes screen,
// the per-project "trust this folder?" dialog, and (running with
// --dangerously-skip-permissions) a one-time bypass-permissions warning — none of
// which a real, already-onboarded profile shows again. Pre-seeds that same
// already-onboarded state instead of leaving a human to click through it.
skipFirstRunPrompts: true,
},
},
overlays: {
+14
View File
@@ -522,6 +522,19 @@ export interface CliCapabilities {
* `customApiKeyResponses.approved` field this pre-seeds — the exact field a real answered
* prompt itself writes to, so this isn't bypassing the check, just answering it the same
* way a one-off prior approval on a shared profile already would.
*
* `skipFirstRunPrompts` (env kind only, alongside apiKeyTrustFile): an isolated config
* directory is not just missing API-key approvals — it is a brand-new profile as far as
* the CLI is concerned, so it also replays its ENTIRE first-run sequence on every launch:
* the theme picker, the security-notes screen, the per-project "trust this folder?"
* dialog, and (running with a bypass-permissions flag) a one-time warning about it —
* confirmed live, none of which a real, long-used profile ever shows again. `true`
* pre-seeds the same state a real profile accumulates from having answered all of that
* once: `hasCompletedOnboarding` and the launching session's own project entry in the
* `apiKeyTrustFile` (claude's `.claude.json`), plus `skipDangerousModePermissionPrompt`
* in claude's `settings.json` — see `seedFirstRunState`/`seedSkipBypassPermissionsPrompt`
* in custom-model-injection-apply.ts. Requires `apiKeyTrustFile` to be set too, since it
* reuses that file.
*/
customModelInjection:
| {
@@ -533,6 +546,7 @@ export interface CliCapabilities {
contextLengthVar?: string;
apiKeyTrustFile?: { relPath: string; shape: 'claude-api-key-responses' };
configDirVar?: string;
skipFirstRunPrompts?: boolean;
}
| { kind: 'configContentEnv'; envVar: string; template: 'opencode-json'; launchModel?: string }
| {
+84 -1
View File
@@ -118,6 +118,79 @@ function seedApiKeyTrustFile(
}
}
/**
* Pre-seeds the two remaining pieces of "already been onboarded" state a fresh
* `CLAUDE_CONFIG_DIR` has none of (`customModelInjection.skipFirstRunPrompts`, alongside
* apiKeyTrustFile): claude replays its whole first-run sequence — the theme picker, the
* security-notes screen, and (per-project) the "trust this folder?" dialog — against ANY
* config directory that has never completed it, confirmed live against a genuinely fresh
* isolated directory. `hasCompletedOnboarding` skips the theme/security-notes screens
* outright; `projects[workingDir].hasTrustDialogAccepted` answers the trust dialog for
* THIS session's own working directory the same way a real profile's own prior approval
* would — other projects in the file are left alone, and `workingDir` is used verbatim
* (never realpath'd or slash-normalized) since that's the literal string claude itself
* uses as the project key, being whatever string the session was actually launched with
* as its cwd.
*
* Same merge-not-overwrite and corrupt-file-tolerant behavior as `seedApiKeyTrustFile`
* (same file, so a second sequential read-modify-write here is deliberate rather than
* folding both into one pass — keeps each seed independently testable and optional).
*/
function seedFirstRunOnboardingState(
configDir: string,
trustFile: { relPath: string; shape: 'claude-api-key-responses' },
workingDir: string
): void {
const filePath = join(configDir, trustFile.relPath);
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(readFileSync(filePath, 'utf8')) as Record<string, unknown>;
} catch {
existing = {};
}
existing.hasCompletedOnboarding = true;
const projects =
existing.projects && typeof existing.projects === 'object' && !Array.isArray(existing.projects)
? (existing.projects as Record<string, Record<string, unknown>>)
: {};
const existingProject = projects[workingDir] && typeof projects[workingDir] === 'object' ? projects[workingDir] : {};
projects[workingDir] = { ...existingProject, hasTrustDialogAccepted: true };
existing.projects = projects;
try {
writeFileSync(filePath, JSON.stringify(existing, null, 2), { encoding: 'utf8', mode: 0o600 });
chmodSync(filePath, 0o600);
} catch {
// best-effort only — the interactive dialogs return instead of a hard failure here
}
}
/**
* Pre-seeds the "skip the bypass-permissions warning" setting (`customModelInjection.
* skipFirstRunPrompts`, alongside apiKeyTrustFile) into an isolated config directory's
* `settings.json` — a real, already-onboarded profile answers claude's one-time warning
* about running with a bypass-permissions flag once and never sees it again, but every
* custom-model session launches with a fresh, otherwise-empty CLAUDE_CONFIG_DIR that
* carries none of that (confirmed live). A different file from apiKeyTrustFile's
* `.claude.json` — this is claude's own global `settings.json`, not project-keyed —
* so it gets its own merge-not-overwrite read-modify-write.
*/
function seedSkipBypassPermissionsPrompt(configDir: string): void {
const filePath = join(configDir, 'settings.json');
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(readFileSync(filePath, 'utf8')) as Record<string, unknown>;
} catch {
existing = {};
}
existing.skipDangerousModePermissionPrompt = true;
try {
writeFileSync(filePath, JSON.stringify(existing, null, 2), { encoding: 'utf8', mode: 0o600 });
chmodSync(filePath, 0o600);
} catch {
// best-effort only — the interactive warning returns instead of a hard failure here
}
}
/** Best-effort recursive removal of a previously-written configDir. Never throws. */
export function removeConfigDir(dir: string | undefined): void {
if (!dir) return;
@@ -151,7 +224,13 @@ export function applyCustomModelInjection(
modelId: string,
sessionId: string,
/** Discovered context-window size for `modelId`, if known — see `contextLengthVar`. */
contextLength?: number
contextLength?: number,
/**
* The session's own working directory — only used for `skipFirstRunPrompts`'s per-project
* trust-dialog seed, and only when provided (boot recovery, which has no reason to
* re-answer a dialog that already fired once, omits it rather than re-deriving it).
*/
workingDir?: string
): AppliedCustomModel | undefined {
const injection = buildCustomModelInjection(entry, endpoint, modelId, contextLength);
if (injection.kind === 'unsupported') return undefined;
@@ -170,6 +249,10 @@ export function applyCustomModelInjection(
if (injection.apiKeyTrustFile && injection.apiKey) {
seedApiKeyTrustFile(configDir, injection.apiKeyTrustFile, injection.apiKey);
}
if (injection.skipFirstRunPrompts && injection.apiKeyTrustFile) {
if (workingDir) seedFirstRunOnboardingState(configDir, injection.apiKeyTrustFile, workingDir);
seedSkipBypassPermissionsPrompt(configDir);
}
envOverrides = { ...envOverrides, [injection.configDirVar]: configDir };
}
return {
+3
View File
@@ -56,6 +56,8 @@ export interface EnvInjection {
apiKeyTrustFile?: { relPath: string; shape: 'claude-api-key-responses' };
/** The literal API key value this injection used, for `apiKeyTrustFile` to pre-approve. */
apiKey?: string;
/** See `customModelInjection.skipFirstRunPrompts` — carried through so the IO wrapper can seed it. */
skipFirstRunPrompts?: boolean;
}
export interface ConfigDirInjection {
@@ -122,6 +124,7 @@ export function buildCustomModelInjection(
let result: EnvInjection = withLaunchModel({ kind: 'env', envOverrides }, cap.launchModel, modelId);
if (cap.configDirVar) result = { ...result, configDirVar: cap.configDirVar };
if (cap.apiKeyTrustFile) result = { ...result, apiKeyTrustFile: cap.apiKeyTrustFile, apiKey };
if (cap.skipFirstRunPrompts) result = { ...result, skipFirstRunPrompts: true };
return result;
}
+10 -2
View File
@@ -1272,7 +1272,14 @@ export function registerSessionRoutes(
// fails its pattern rather than quoting it, which would silently launch the CLI on its
// own default provider again, so refuse an id the pattern cannot carry up front.
const modelSpec = entry.launch.params.model;
const applied = applyCustomModelInjection(entry, endpoint, body.modelId, session.id, contextLength);
const applied = applyCustomModelInjection(
entry,
endpoint,
body.modelId,
session.id,
contextLength,
session.workingDir
);
if (!applied) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`);
}
@@ -3643,7 +3650,8 @@ export function registerSessionRoutes(
cmEndpoint,
customModel.modelId,
qsCustomModelSessionId,
cmContextLength
cmContextLength,
resolvedCasePath
);
if (!cmApplied) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);