diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index 33ca2947a68d..8546c13825ad 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -38,9 +38,10 @@ not install or modify anything on the remote host. - With a configured default model, **Keep existing model config** appears first and becomes the default, followed by **QuickStart (recommended)** and **Manual setup**. - An explicit non-`skip` `--auth-choice` still configures that provider - without changing the existing default model, unless the provider requires - you to select a model. + An explicit non-`skip` `--auth-choice` or a single provider credential + flag still configures that provider without changing the existing default + model, unless the provider requires you to select a model. Multiple + provider flags require an explicit `--auth-choice`. - When a migration provider is available, **Import from another agent** appears after those setup choices. Selecting it opens a provider list with entries such as **Import from Claude**, **Import from Codex**, and diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts index b88b7b3db492..2973a7678750 100644 --- a/src/commands/onboard.test.ts +++ b/src/commands/onboard.test.ts @@ -997,6 +997,12 @@ describe("setupWizardCommand", () => { expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled(); }); + it("rejects ambiguous interactive provider flags before reset", async () => { + const runtime = makeRuntime(); + await setupWizardCommand({ reset: true, nvidiaApiKey: "n", openaiApiKey: "o" }, runtime); + expect(mocks.handleReset).not.toHaveBeenCalled(); + }); + it("validates custom credential storage before reset", async () => { const runtime = makeRuntime(); vi.stubEnv("CUSTOM_API_KEY", ""); diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index a941c9b80e52..832231f65dac 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -214,7 +214,9 @@ async function validateResetAuthChoice(params: { resetScope: ResetScope; }): Promise { const inferredAuthChoice = - params.opts.authChoice || !params.opts.nonInteractive + params.opts.authChoice || + params.opts.mode === "remote" || + (!params.opts.nonInteractive && !wantsClassicInteractiveSetup(params.opts)) ? undefined : inferAuthChoiceFromFlags(params.opts, { config: params.baseConfig, @@ -225,12 +227,15 @@ async function validateResetAuthChoice(params: { return rejectOption( params.runtime, [ - "Multiple API key flags were provided for non-interactive setup.", + `Multiple ${params.opts.nonInteractive ? "API key" : "provider credential"} flags were provided for ${params.opts.nonInteractive ? "non-interactive" : "interactive"} setup.`, "Use a single provider flag or pass --auth-choice explicitly.", `Flags: ${inferredAuthChoice.matches.map((match) => match.label).join(", ")}`, ].join("\n"), ); } + if (!params.opts.nonInteractive && inferredAuthChoice) { + return true; + } const authChoice = params.opts.authChoice ?? inferredAuthChoice?.choice; if (!authChoice) { return true; diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 9e4adc32d42a..9c0262cc3134 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -32,6 +32,8 @@ type ResolvePluginSetupProvider = typeof import("../plugins/provider-auth-choice.runtime.js").resolvePluginSetupProvider; type ResolveManifestProviderAuthChoice = typeof import("../plugins/provider-auth-choices.js").resolveManifestProviderAuthChoice; +type ResolveProviderOnboardAuthFlags = + typeof import("../plugins/provider-auth-choices.js").resolveProviderOnboardAuthFlags; type PromptDefaultModel = typeof import("../commands/model-picker.js").promptDefaultModel; type ApplyAuthChoice = typeof import("../commands/auth-choice.js").applyAuthChoice; type PrepareAuthChoice = typeof import("../commands/auth-choice.js").prepareAuthChoice; @@ -59,6 +61,9 @@ const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => const resolveManifestProviderAuthChoice = vi.hoisted(() => vi.fn(() => undefined), ); +const resolveProviderOnboardAuthFlags = vi.hoisted(() => + vi.fn(() => []), +); const resolvePluginSetupProvider = vi.hoisted(() => vi.fn(() => undefined), ); @@ -383,6 +388,7 @@ vi.mock("../commands/auth-choice.js", () => ({ vi.mock("../plugins/provider-auth-choices.js", () => ({ resolveManifestProviderAuthChoice, resolveManifestProviderAuthChoices: () => [], + resolveProviderOnboardAuthFlags, })); vi.mock("../plugins/setup-registry.js", () => ({ @@ -672,6 +678,8 @@ describe("runSetupWizard", () => { resolvePluginProvidersRuntime.mockReturnValue([]); resolveManifestProviderAuthChoice.mockReset(); resolveManifestProviderAuthChoice.mockReturnValue(undefined); + resolveProviderOnboardAuthFlags.mockReset(); + resolveProviderOnboardAuthFlags.mockReturnValue([]); resolvePluginSetupProvider.mockReset(); resolvePluginSetupProvider.mockReturnValue(undefined); resolveProviderPluginChoice.mockReset(); @@ -2553,6 +2561,129 @@ describe("runSetupWizard", () => { }); }); + it.each([ + { + name: "an API-key flag", + optionKey: "nvidiaApiKey", + authChoice: "nvidia-api-key", + cliFlag: "--nvidia-api-key", + }, + { + name: "a provider token flag", + optionKey: "githubCopilotToken", + authChoice: "github-copilot", + cliFlag: "--github-copilot-token", + }, + ] as const)( + "infers $name while preserving an existing default model", + async ({ optionKey, authChoice, cliFlag }) => { + resolveProviderOnboardAuthFlags.mockReturnValue([ + { + optionKey, + authChoice, + cliFlag, + cliOption: `${cliFlag} `, + description: "Provider credential", + }, + ]); + const existingConfig: OpenClawConfig = { + agents: { + defaults: { model: { primary: "anthropic/sonnet-4.6" } }, + entries: { main: { default: true } }, + }, + }; + readConfigFileSnapshot.mockImplementation(async () => + configSnapshot(persistedWizardConfigs().at(-1) ?? existingConfig), + ); + + await runSetupWizard( + { + acceptRisk: true, + [optionKey]: "provider-credential-fixture", + installDaemon: false, + skipChannels: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + createRuntime(), + buildWizardPrompter({}, { defaultSelect: "keep-model" }), + ); + + expect(prepareAuthChoice).toHaveBeenCalledWith( + expect.objectContaining({ + authChoice, + opts: expect.objectContaining({ [optionKey]: "provider-credential-fixture" }), + }), + ); + expect(persistedWizardConfigs().at(-1)?.agents?.defaults?.model).toEqual({ + primary: "anthropic/sonnet-4.6", + }); + }, + ); + + it("rejects ambiguous provider credential flags before writing local setup state", async () => { + resolveProviderOnboardAuthFlags.mockReturnValue([ + { + optionKey: "nvidiaApiKey", + authChoice: "nvidia-api-key", + cliFlag: "--nvidia-api-key", + cliOption: "--nvidia-api-key ", + description: "NVIDIA API key", + }, + { + optionKey: "githubCopilotToken", + authChoice: "github-copilot", + cliFlag: "--github-copilot-token", + cliOption: "--github-copilot-token ", + description: "GitHub Copilot token", + }, + ]); + const runtime = createRuntime(); + + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + nvidiaApiKey: "nvidia-credential-fixture", + githubCopilotToken: "copilot-credential-fixture", + }, + runtime, + buildWizardPrompter({}), + ); + + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("Multiple provider credential flags"), + ); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(prepareAuthChoice).not.toHaveBeenCalled(); + expect(ensureOnboardingConfig).not.toHaveBeenCalled(); + expect(replaceConfigFile).not.toHaveBeenCalled(); + }); + + it("keeps an explicit auth skip cold when a provider credential flag is supplied", async () => { + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + authChoice: "skip", + nvidiaApiKey: "nvidia-credential-fixture", + installDaemon: false, + skipChannels: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + createRuntime(), + buildWizardPrompter({}), + ); + + expect(resolveProviderOnboardAuthFlags).not.toHaveBeenCalled(); + expect(prepareAuthChoice).not.toHaveBeenCalled(); + }); + it("prompts for a model during explicit interactive Ollama setup", async () => { promptDefaultModel.mockClear(); warnIfModelConfigLooksOff.mockClear(); diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 5fa72e44ea1c..2387a85aed5f 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -67,10 +67,11 @@ export async function runSetupWizard( } async function runSetupWizardOnce( - opts: OnboardOptions, + initialOpts: OnboardOptions, runtimeInput: RuntimeEnv | undefined, prompter: WizardPrompter, ) { + let opts = initialOpts; const runtime = runtimeInput ?? defaultRuntime; const onboardHelpers = await import("../commands/onboard-helpers.js"); await onboardHelpers.printWizardHeader(runtime); @@ -498,6 +499,18 @@ async function runSetupWizardOnce( prompter, hasAuthoredRoster, }); + if (opts.authChoice === undefined) { + const { inferAuthChoiceFromFlags } = + await import("../commands/onboard-non-interactive/local/auth-choice-inference.js"); + const inferred = inferAuthChoiceFromFlags(opts, { config: baseConfig, workspaceDir }); + if (inferred.matches.length > 1) { + runtime.error( + `Multiple provider credential flags (${inferred.matches.map((match) => match.label).join(", ")}). Use one flag or pass --auth-choice explicitly.`, + ); + return runtime.exit(1); + } + opts = inferred.choice ? { ...opts, authChoice: inferred.choice } : opts; + } const firstAgent = await firstAgentOnboarding.promptFirstOnboardingAgent( hasAuthoredRoster, opts.agentName,