mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 06:29:42 +02:00
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:
co-authored by
Claude Sonnet 5
parent
2c89359d42
commit
211b872335
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }
|
||||
| {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user