mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
feat(onboarding): add web search to onboarding flow (#34009)
* add web search to onboarding flow * remove post onboarding step (now redundant) * post-onboarding nudge if no web search set up * address comments * fix test mocking * add enabled: false assertion to the no-key test * --skip-search cli flag * use provider that a user has a key for * add assertions, replace the duplicated switch blocks * test for quickstart fast-path with existing config key * address comments * cover quickstart falls through to key test * bring back key source * normalize secret inputs instead of direct string trimming * preserve enabled: false if it's already set * handle missing API keys in flow * doc updates * hasExistingKey to detect both plaintext strings and SecretRef objects * preserve enabled state only on the "keep current" paths * add test for preserving * better gate flows * guard against invalid provider values in config * Update src/commands/configure.wizard.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * format fix * only mentions env var when it's actually available * search apiKey fields now typed as SecretInput * if no provider check if any search provider key is detectable * handle both kimi keys * remove .filter(Boolean) * do not disable web_search after user enables it * update resolveSearchProvider * fix(onboarding): skip search key prompt in ref mode * fix: add onboarding web search step (#34009) (thanks @kesku) --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/context engine plugin interface: add `ContextEngine` plugin slot with full lifecycle hooks (`bootstrap`, `ingest`, `assemble`, `compact`, `afterTurn`, `prepareSubagentSpawn`, `onSubagentEnded`), slot-based registry with config-driven resolution, `LegacyContextEngine` wrapper preserving existing compaction behavior, scoped subagent runtime for plugin runtimes via `AsyncLocalStorage`, and `sessions.get` gateway method. Enables plugins like `lossless-claw` to provide alternative context management strategies without modifying core compaction logic. Zero behavior change when no context engine plugin is configured. (#22201) thanks @jalehman.
|
||||
- CLI: make read-only SecretRef status flows degrade safely (#37023) thanks @joshavant.
|
||||
- Docker/Podman extension dependency baking: add `OPENCLAW_EXTENSIONS` so container builds can preinstall selected bundled extension npm dependencies into the image for faster and more reproducible startup in container deployments. (#32223) Thanks @sallyom.
|
||||
- Onboarding/web search: add provider selection step and full provider list in configure wizard, with SecretRef ref-mode support during onboarding. (#34009) Thanks @kesku and @thewilloftheshadow.
|
||||
|
||||
### Breaking
|
||||
|
||||
|
||||
@@ -75,12 +75,15 @@ You can keep it local with `memorySearch.provider = "local"` (no API usage).
|
||||
|
||||
See [Memory](/concepts/memory).
|
||||
|
||||
### 4) Web search tool (Brave / Perplexity via OpenRouter)
|
||||
### 4) Web search tool
|
||||
|
||||
`web_search` uses API keys and may incur usage charges:
|
||||
`web_search` uses API keys and may incur usage charges depending on your provider:
|
||||
|
||||
- **Perplexity Search API**: `PERPLEXITY_API_KEY`
|
||||
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
||||
- **Perplexity** (via OpenRouter): `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
|
||||
- **Gemini (Google Search)**: `GEMINI_API_KEY`
|
||||
- **Grok (xAI)**: `XAI_API_KEY`
|
||||
- **Kimi (Moonshot)**: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
|
||||
|
||||
See [Web tools](/tools/web).
|
||||
|
||||
|
||||
@@ -94,6 +94,12 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
|
||||
- [iMessage](/channels/imessage): legacy `imsg` CLI path + DB access.
|
||||
- DM security: default is pairing. First DM sends a code; approve via `openclaw pairing approve <channel> <code>` or use allowlists.
|
||||
</Step>
|
||||
<Step title="Web search">
|
||||
- Pick a provider: Perplexity, Brave, Gemini, Grok, or Kimi (or skip).
|
||||
- Paste your API key (QuickStart auto-detects keys from env vars or existing config).
|
||||
- Skip with `--skip-search`.
|
||||
- Configure later: `openclaw configure --section web`.
|
||||
</Step>
|
||||
<Step title="Daemon install">
|
||||
- macOS: LaunchAgent
|
||||
- Requires a logged-in user session; for headless, use a custom LaunchDaemon (not shipped).
|
||||
|
||||
@@ -35,9 +35,10 @@ openclaw agents add <name>
|
||||
</Note>
|
||||
|
||||
<Tip>
|
||||
Recommended: set up a Brave Search API key so the agent can use `web_search`
|
||||
(`web_fetch` works without a key). Easiest path: `openclaw configure --section web`
|
||||
which stores `tools.web.search.apiKey`. Docs: [Web tools](/tools/web).
|
||||
The onboarding wizard includes a web search step where you can pick a provider
|
||||
(Perplexity, Brave, Gemini, Grok, or Kimi) and paste your API key so the agent
|
||||
can use `web_search`. You can also configure this later with
|
||||
`openclaw configure --section web`. Docs: [Web tools](/tools/web).
|
||||
</Tip>
|
||||
|
||||
## QuickStart vs Advanced
|
||||
|
||||
@@ -256,7 +256,7 @@ Enable with `tools.loopDetection.enabled: true` (default is `false`).
|
||||
|
||||
### `web_search`
|
||||
|
||||
Search the web using Brave Search API.
|
||||
Search the web using Perplexity, Brave, Gemini, Grok, or Kimi.
|
||||
|
||||
Core parameters:
|
||||
|
||||
@@ -265,7 +265,7 @@ Core parameters:
|
||||
|
||||
Notes:
|
||||
|
||||
- Requires a Brave API key (recommended: `openclaw configure --section web`, or set `BRAVE_API_KEY`).
|
||||
- Requires an API key for the chosen provider (recommended: `openclaw configure --section web`).
|
||||
- Enable via `tools.web.search.enabled`.
|
||||
- Responses are cached (default 15 min).
|
||||
- See [Web tools](/tools/web) for setup.
|
||||
|
||||
@@ -505,30 +505,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
|
||||
|
||||
// Auto-detect provider from available API keys (priority order)
|
||||
if (raw === "") {
|
||||
// 1. Brave
|
||||
if (resolveSearchApiKey(search)) {
|
||||
logVerbose(
|
||||
'web_search: no provider configured, auto-detected "brave" from available API keys',
|
||||
);
|
||||
return "brave";
|
||||
}
|
||||
// 2. Gemini
|
||||
const geminiConfig = resolveGeminiConfig(search);
|
||||
if (resolveGeminiApiKey(geminiConfig)) {
|
||||
logVerbose(
|
||||
'web_search: no provider configured, auto-detected "gemini" from available API keys',
|
||||
);
|
||||
return "gemini";
|
||||
}
|
||||
// 3. Kimi
|
||||
const kimiConfig = resolveKimiConfig(search);
|
||||
if (resolveKimiApiKey(kimiConfig)) {
|
||||
logVerbose(
|
||||
'web_search: no provider configured, auto-detected "kimi" from available API keys',
|
||||
);
|
||||
return "kimi";
|
||||
}
|
||||
// 4. Perplexity
|
||||
// 1. Perplexity
|
||||
const perplexityConfig = resolvePerplexityConfig(search);
|
||||
const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig);
|
||||
if (perplexityKey) {
|
||||
@@ -537,7 +514,22 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
|
||||
);
|
||||
return "perplexity";
|
||||
}
|
||||
// 5. Grok
|
||||
// 2. Brave
|
||||
if (resolveSearchApiKey(search)) {
|
||||
logVerbose(
|
||||
'web_search: no provider configured, auto-detected "brave" from available API keys',
|
||||
);
|
||||
return "brave";
|
||||
}
|
||||
// 3. Gemini
|
||||
const geminiConfig = resolveGeminiConfig(search);
|
||||
if (resolveGeminiApiKey(geminiConfig)) {
|
||||
logVerbose(
|
||||
'web_search: no provider configured, auto-detected "gemini" from available API keys',
|
||||
);
|
||||
return "gemini";
|
||||
}
|
||||
// 4. Grok
|
||||
const grokConfig = resolveGrokConfig(search);
|
||||
if (resolveGrokApiKey(grokConfig)) {
|
||||
logVerbose(
|
||||
@@ -545,9 +537,17 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
|
||||
);
|
||||
return "grok";
|
||||
}
|
||||
// 5. Kimi
|
||||
const kimiConfig = resolveKimiConfig(search);
|
||||
if (resolveKimiApiKey(kimiConfig)) {
|
||||
logVerbose(
|
||||
'web_search: no provider configured, auto-detected "kimi" from available API keys',
|
||||
);
|
||||
return "kimi";
|
||||
}
|
||||
}
|
||||
|
||||
return "brave";
|
||||
return "perplexity";
|
||||
}
|
||||
|
||||
function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig {
|
||||
|
||||
@@ -119,6 +119,7 @@ export function registerOnboardCommand(program: Command) {
|
||||
.option("--daemon-runtime <runtime>", "Daemon runtime: node|bun")
|
||||
.option("--skip-channels", "Skip channel setup")
|
||||
.option("--skip-skills", "Skip skills setup")
|
||||
.option("--skip-search", "Skip search provider setup")
|
||||
.option("--skip-health", "Skip health check")
|
||||
.option("--skip-ui", "Skip Control UI/TUI prompts")
|
||||
.option("--node-manager <name>", "Node manager for skills: npm|pnpm|bun")
|
||||
@@ -193,6 +194,7 @@ export function registerOnboardCommand(program: Command) {
|
||||
daemonRuntime: opts.daemonRuntime as GatewayDaemonRuntime | undefined,
|
||||
skipChannels: Boolean(opts.skipChannels),
|
||||
skipSkills: Boolean(opts.skipSkills),
|
||||
skipSearch: Boolean(opts.skipSearch),
|
||||
skipHealth: Boolean(opts.skipHealth),
|
||||
skipUi: Boolean(opts.skipUi),
|
||||
nodeManager: opts.nodeManager as NodeManagerChoice | undefined,
|
||||
|
||||
@@ -166,18 +166,35 @@ async function promptWebToolsConfig(
|
||||
): Promise<OpenClawConfig> {
|
||||
const existingSearch = nextConfig.tools?.web?.search;
|
||||
const existingFetch = nextConfig.tools?.web?.fetch;
|
||||
const existingProvider = existingSearch?.provider ?? "brave";
|
||||
const hasPerplexityKey = Boolean(
|
||||
existingSearch?.perplexity?.apiKey || process.env.PERPLEXITY_API_KEY,
|
||||
);
|
||||
const hasBraveKey = Boolean(existingSearch?.apiKey || process.env.BRAVE_API_KEY);
|
||||
const hasSearchKey = existingProvider === "perplexity" ? hasPerplexityKey : hasBraveKey;
|
||||
const {
|
||||
SEARCH_PROVIDER_OPTIONS,
|
||||
resolveExistingKey,
|
||||
hasExistingKey,
|
||||
applySearchKey,
|
||||
hasKeyInEnv,
|
||||
} = await import("./onboard-search.js");
|
||||
type SP = (typeof SEARCH_PROVIDER_OPTIONS)[number]["value"];
|
||||
|
||||
const hasKeyForProvider = (provider: string): boolean => {
|
||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
return hasExistingKey(nextConfig, provider as SP) || hasKeyInEnv(entry);
|
||||
};
|
||||
|
||||
const existingProvider: string = (() => {
|
||||
const stored = existingSearch?.provider;
|
||||
if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) {
|
||||
return stored;
|
||||
}
|
||||
return SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? "perplexity";
|
||||
})();
|
||||
|
||||
note(
|
||||
[
|
||||
"Web search lets your agent look things up online using the `web_search` tool.",
|
||||
"Choose a provider: Perplexity Search (recommended) or Brave Search.",
|
||||
"Both return structured results (title, URL, snippet) for fast research.",
|
||||
"Choose a provider and paste your API key.",
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
@@ -186,30 +203,31 @@ async function promptWebToolsConfig(
|
||||
const enableSearch = guardCancel(
|
||||
await confirm({
|
||||
message: "Enable web_search?",
|
||||
initialValue: existingSearch?.enabled ?? hasSearchKey,
|
||||
initialValue:
|
||||
existingSearch?.enabled ?? SEARCH_PROVIDER_OPTIONS.some((e) => hasKeyForProvider(e.value)),
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
|
||||
let nextSearch = {
|
||||
let nextSearch: Record<string, unknown> = {
|
||||
...existingSearch,
|
||||
enabled: enableSearch,
|
||||
};
|
||||
|
||||
if (enableSearch) {
|
||||
const providerOptions = SEARCH_PROVIDER_OPTIONS.map((entry) => {
|
||||
const configured = hasKeyForProvider(entry.value);
|
||||
return {
|
||||
value: entry.value,
|
||||
label: entry.label,
|
||||
hint: configured ? `${entry.hint} · configured` : entry.hint,
|
||||
};
|
||||
});
|
||||
|
||||
const providerChoice = guardCancel(
|
||||
await select({
|
||||
message: "Choose web search provider",
|
||||
options: [
|
||||
{
|
||||
value: "perplexity",
|
||||
label: "Perplexity Search",
|
||||
},
|
||||
{
|
||||
value: "brave",
|
||||
label: "Brave Search",
|
||||
},
|
||||
],
|
||||
options: providerOptions,
|
||||
initialValue: existingProvider,
|
||||
}),
|
||||
runtime,
|
||||
@@ -217,59 +235,42 @@ async function promptWebToolsConfig(
|
||||
|
||||
nextSearch = { ...nextSearch, provider: providerChoice };
|
||||
|
||||
if (providerChoice === "perplexity") {
|
||||
const hasKey = Boolean(existingSearch?.perplexity?.apiKey);
|
||||
const keyInput = guardCancel(
|
||||
await text({
|
||||
message: hasKey
|
||||
? "Perplexity API key (leave blank to keep current or use PERPLEXITY_API_KEY)"
|
||||
: "Perplexity API key (paste it here; leave blank to use PERPLEXITY_API_KEY)",
|
||||
placeholder: hasKey ? "Leave blank to keep current" : "pplx-...",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
const key = String(keyInput ?? "").trim();
|
||||
if (key) {
|
||||
nextSearch = {
|
||||
...nextSearch,
|
||||
perplexity: { ...existingSearch?.perplexity, apiKey: key },
|
||||
};
|
||||
} else if (!hasKey && !process.env.PERPLEXITY_API_KEY) {
|
||||
note(
|
||||
[
|
||||
"No key stored yet, so web_search will stay unavailable.",
|
||||
"Store a key here or set PERPLEXITY_API_KEY in the Gateway environment.",
|
||||
"Get your API key at: https://www.perplexity.ai/settings/api",
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
}
|
||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!;
|
||||
const existingKey = resolveExistingKey(nextConfig, providerChoice as SP);
|
||||
const keyConfigured = hasExistingKey(nextConfig, providerChoice as SP);
|
||||
const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
|
||||
const envVarNames = entry.envKeys.join(" / ");
|
||||
|
||||
const keyInput = guardCancel(
|
||||
await text({
|
||||
message: keyConfigured
|
||||
? envAvailable
|
||||
? `${entry.label} API key (leave blank to keep current or use ${envVarNames})`
|
||||
: `${entry.label} API key (leave blank to keep current)`
|
||||
: envAvailable
|
||||
? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})`
|
||||
: `${entry.label} API key`,
|
||||
placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
const key = String(keyInput ?? "").trim();
|
||||
|
||||
if (key || existingKey) {
|
||||
const applied = applySearchKey(nextConfig, providerChoice as SP, (key || existingKey)!);
|
||||
nextSearch = { ...applied.tools?.web?.search };
|
||||
} else if (keyConfigured || envAvailable) {
|
||||
nextSearch = { ...nextSearch };
|
||||
} else {
|
||||
const hasKey = Boolean(existingSearch?.apiKey);
|
||||
const keyInput = guardCancel(
|
||||
await text({
|
||||
message: hasKey
|
||||
? "Brave Search API key (leave blank to keep current or use BRAVE_API_KEY)"
|
||||
: "Brave Search API key (paste it here; leave blank to use BRAVE_API_KEY)",
|
||||
placeholder: hasKey ? "Leave blank to keep current" : "BSA...",
|
||||
}),
|
||||
runtime,
|
||||
note(
|
||||
[
|
||||
"No key stored yet — web_search won't work until a key is available.",
|
||||
`Store a key here or set ${envVarNames} in the Gateway environment.`,
|
||||
`Get your API key at: ${entry.signupUrl}`,
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
const key = String(keyInput ?? "").trim();
|
||||
if (key) {
|
||||
nextSearch = { ...nextSearch, apiKey: key };
|
||||
} else if (!hasKey && !process.env.BRAVE_API_KEY) {
|
||||
note(
|
||||
[
|
||||
"No key stored yet, so web_search will stay unavailable.",
|
||||
"Store a key here or set BRAVE_API_KEY in the Gateway environment.",
|
||||
"Get your API key at: https://brave.com/search/api/",
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
279
src/commands/onboard-search.test.ts
Normal file
279
src/commands/onboard-search.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import { SEARCH_PROVIDER_OPTIONS, setupSearch } from "./onboard-search.js";
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`unexpected exit ${code}`);
|
||||
}) as RuntimeEnv["exit"],
|
||||
};
|
||||
|
||||
function createPrompter(params: { selectValue?: string; textValue?: string }): {
|
||||
prompter: WizardPrompter;
|
||||
notes: Array<{ title?: string; message: string }>;
|
||||
} {
|
||||
const notes: Array<{ title?: string; message: string }> = [];
|
||||
const prompter: WizardPrompter = {
|
||||
intro: vi.fn(async () => {}),
|
||||
outro: vi.fn(async () => {}),
|
||||
note: vi.fn(async (message: string, title?: string) => {
|
||||
notes.push({ title, message });
|
||||
}),
|
||||
select: vi.fn(
|
||||
async () => params.selectValue ?? "perplexity",
|
||||
) as unknown as WizardPrompter["select"],
|
||||
multiselect: vi.fn(async () => []) as unknown as WizardPrompter["multiselect"],
|
||||
text: vi.fn(async () => params.textValue ?? ""),
|
||||
confirm: vi.fn(async () => true),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
};
|
||||
return { prompter, notes };
|
||||
}
|
||||
|
||||
describe("setupSearch", () => {
|
||||
it("returns config unchanged when user skips", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({ selectValue: "__skip__" });
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result).toBe(cfg);
|
||||
});
|
||||
|
||||
it("sets provider and key for perplexity", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({
|
||||
selectValue: "perplexity",
|
||||
textValue: "pplx-test-key",
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.provider).toBe("perplexity");
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("pplx-test-key");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("sets provider and key for brave", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({
|
||||
selectValue: "brave",
|
||||
textValue: "BSA-test-key",
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.provider).toBe("brave");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(result.tools?.web?.search?.apiKey).toBe("BSA-test-key");
|
||||
});
|
||||
|
||||
it("sets provider and key for gemini", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({
|
||||
selectValue: "gemini",
|
||||
textValue: "AIza-test",
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.provider).toBe("gemini");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(result.tools?.web?.search?.gemini?.apiKey).toBe("AIza-test");
|
||||
});
|
||||
|
||||
it("sets provider and key for grok", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({
|
||||
selectValue: "grok",
|
||||
textValue: "xai-test",
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.provider).toBe("grok");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(result.tools?.web?.search?.grok?.apiKey).toBe("xai-test");
|
||||
});
|
||||
|
||||
it("sets provider and key for kimi", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({
|
||||
selectValue: "kimi",
|
||||
textValue: "sk-moonshot",
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.provider).toBe("kimi");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(result.tools?.web?.search?.kimi?.apiKey).toBe("sk-moonshot");
|
||||
});
|
||||
|
||||
it("shows missing-key note when no key is provided and no env var", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter, notes } = createPrompter({
|
||||
selectValue: "brave",
|
||||
textValue: "",
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.provider).toBe("brave");
|
||||
expect(result.tools?.web?.search?.enabled).toBeUndefined();
|
||||
const missingNote = notes.find((n) => n.message.includes("No API key stored"));
|
||||
expect(missingNote).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps existing key when user leaves input blank", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
perplexity: { apiKey: "existing-key" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { prompter } = createPrompter({
|
||||
selectValue: "perplexity",
|
||||
textValue: "",
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("existing-key");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("advanced preserves enabled:false when keeping existing key", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
enabled: false,
|
||||
perplexity: { apiKey: "existing-key" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { prompter } = createPrompter({
|
||||
selectValue: "perplexity",
|
||||
textValue: "",
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("existing-key");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("quickstart skips key prompt when config key exists", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
perplexity: { apiKey: "stored-pplx-key" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { prompter } = createPrompter({ selectValue: "perplexity" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
quickstartDefaults: true,
|
||||
});
|
||||
expect(result.tools?.web?.search?.provider).toBe("perplexity");
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("quickstart preserves enabled:false when search was intentionally disabled", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
enabled: false,
|
||||
perplexity: { apiKey: "stored-pplx-key" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { prompter } = createPrompter({ selectValue: "perplexity" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
quickstartDefaults: true,
|
||||
});
|
||||
expect(result.tools?.web?.search?.provider).toBe("perplexity");
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(false);
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("quickstart falls through to key prompt when no key and no env var", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({ selectValue: "grok", textValue: "" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
quickstartDefaults: true,
|
||||
});
|
||||
expect(prompter.text).toHaveBeenCalled();
|
||||
expect(result.tools?.web?.search?.provider).toBe("grok");
|
||||
expect(result.tools?.web?.search?.enabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("quickstart skips key prompt when env var is available", async () => {
|
||||
const orig = process.env.BRAVE_API_KEY;
|
||||
process.env.BRAVE_API_KEY = "env-brave-key";
|
||||
try {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({ selectValue: "brave" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
quickstartDefaults: true,
|
||||
});
|
||||
expect(result.tools?.web?.search?.provider).toBe("brave");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (orig === undefined) {
|
||||
delete process.env.BRAVE_API_KEY;
|
||||
} else {
|
||||
process.env.BRAVE_API_KEY = orig;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("stores env-backed SecretRef when secretInputMode=ref for perplexity", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({ selectValue: "perplexity" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
secretInputMode: "ref",
|
||||
});
|
||||
expect(result.tools?.web?.search?.provider).toBe("perplexity");
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "PERPLEXITY_API_KEY",
|
||||
});
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores env-backed SecretRef when secretInputMode=ref for brave", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({ selectValue: "brave" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
secretInputMode: "ref",
|
||||
});
|
||||
expect(result.tools?.web?.search?.provider).toBe("brave");
|
||||
expect(result.tools?.web?.search?.apiKey).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BRAVE_API_KEY",
|
||||
});
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores plaintext key when secretInputMode is unset", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({
|
||||
selectValue: "brave",
|
||||
textValue: "BSA-plain",
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.apiKey).toBe("BSA-plain");
|
||||
});
|
||||
|
||||
it("exports all 5 providers in SEARCH_PROVIDER_OPTIONS", () => {
|
||||
expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(5);
|
||||
const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value);
|
||||
expect(values).toEqual(["perplexity", "brave", "gemini", "grok", "kimi"]);
|
||||
});
|
||||
});
|
||||
319
src/commands/onboard-search.ts
Normal file
319
src/commands/onboard-search.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_SECRET_PROVIDER_ALIAS,
|
||||
type SecretInput,
|
||||
type SecretRef,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeSecretInputString,
|
||||
} from "../config/types.secrets.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import type { SecretInputMode } from "./onboard-types.js";
|
||||
|
||||
export type SearchProvider = "perplexity" | "brave" | "gemini" | "grok" | "kimi";
|
||||
|
||||
type SearchProviderEntry = {
|
||||
value: SearchProvider;
|
||||
label: string;
|
||||
hint: string;
|
||||
envKeys: string[];
|
||||
placeholder: string;
|
||||
signupUrl: string;
|
||||
};
|
||||
|
||||
export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] = [
|
||||
{
|
||||
value: "perplexity",
|
||||
label: "Perplexity Search",
|
||||
hint: "Structured results · domain/language/freshness filters",
|
||||
envKeys: ["PERPLEXITY_API_KEY"],
|
||||
placeholder: "pplx-...",
|
||||
signupUrl: "https://www.perplexity.ai/settings/api",
|
||||
},
|
||||
{
|
||||
value: "brave",
|
||||
label: "Brave Search",
|
||||
hint: "Structured results · region-specific",
|
||||
envKeys: ["BRAVE_API_KEY"],
|
||||
placeholder: "BSA...",
|
||||
signupUrl: "https://brave.com/search/api/",
|
||||
},
|
||||
{
|
||||
value: "gemini",
|
||||
label: "Gemini (Google Search)",
|
||||
hint: "Google Search grounding · AI-synthesized",
|
||||
envKeys: ["GEMINI_API_KEY"],
|
||||
placeholder: "AIza...",
|
||||
signupUrl: "https://aistudio.google.com/apikey",
|
||||
},
|
||||
{
|
||||
value: "grok",
|
||||
label: "Grok (xAI)",
|
||||
hint: "xAI web-grounded responses",
|
||||
envKeys: ["XAI_API_KEY"],
|
||||
placeholder: "xai-...",
|
||||
signupUrl: "https://console.x.ai/",
|
||||
},
|
||||
{
|
||||
value: "kimi",
|
||||
label: "Kimi (Moonshot)",
|
||||
hint: "Moonshot web search",
|
||||
envKeys: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
|
||||
placeholder: "sk-...",
|
||||
signupUrl: "https://platform.moonshot.cn/",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function hasKeyInEnv(entry: SearchProviderEntry): boolean {
|
||||
return entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
|
||||
}
|
||||
|
||||
function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown {
|
||||
const search = config.tools?.web?.search;
|
||||
switch (provider) {
|
||||
case "brave":
|
||||
return search?.apiKey;
|
||||
case "perplexity":
|
||||
return search?.perplexity?.apiKey;
|
||||
case "gemini":
|
||||
return search?.gemini?.apiKey;
|
||||
case "grok":
|
||||
return search?.grok?.apiKey;
|
||||
case "kimi":
|
||||
return search?.kimi?.apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the plaintext key string, or undefined for SecretRefs/missing. */
|
||||
export function resolveExistingKey(
|
||||
config: OpenClawConfig,
|
||||
provider: SearchProvider,
|
||||
): string | undefined {
|
||||
return normalizeSecretInputString(rawKeyValue(config, provider));
|
||||
}
|
||||
|
||||
/** Returns true if a key is configured (plaintext string or SecretRef). */
|
||||
export function hasExistingKey(config: OpenClawConfig, provider: SearchProvider): boolean {
|
||||
return hasConfiguredSecretInput(rawKeyValue(config, provider));
|
||||
}
|
||||
|
||||
/** Build an env-backed SecretRef for a search provider. */
|
||||
function buildSearchEnvRef(provider: SearchProvider): SecretRef {
|
||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider);
|
||||
const envVar = entry?.envKeys.find((k) => Boolean(process.env[k]?.trim())) ?? entry?.envKeys[0];
|
||||
if (!envVar) {
|
||||
throw new Error(
|
||||
`No env var mapping for search provider "${provider}" in secret-input-mode=ref.`,
|
||||
);
|
||||
}
|
||||
return { source: "env", provider: DEFAULT_SECRET_PROVIDER_ALIAS, id: envVar };
|
||||
}
|
||||
|
||||
/** Resolve a plaintext key into the appropriate SecretInput based on mode. */
|
||||
function resolveSearchSecretInput(
|
||||
provider: SearchProvider,
|
||||
key: string,
|
||||
secretInputMode?: SecretInputMode,
|
||||
): SecretInput {
|
||||
if (secretInputMode === "ref") {
|
||||
return buildSearchEnvRef(provider);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export function applySearchKey(
|
||||
config: OpenClawConfig,
|
||||
provider: SearchProvider,
|
||||
key: SecretInput,
|
||||
): OpenClawConfig {
|
||||
const search = { ...config.tools?.web?.search, provider, enabled: true };
|
||||
switch (provider) {
|
||||
case "brave":
|
||||
search.apiKey = key;
|
||||
break;
|
||||
case "perplexity":
|
||||
search.perplexity = { ...search.perplexity, apiKey: key };
|
||||
break;
|
||||
case "gemini":
|
||||
search.gemini = { ...search.gemini, apiKey: key };
|
||||
break;
|
||||
case "grok":
|
||||
search.grok = { ...search.grok, apiKey: key };
|
||||
break;
|
||||
case "kimi":
|
||||
search.kimi = { ...search.kimi, apiKey: key };
|
||||
break;
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
tools: {
|
||||
...config.tools,
|
||||
web: { ...config.tools?.web, search },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): OpenClawConfig {
|
||||
return {
|
||||
...config,
|
||||
tools: {
|
||||
...config.tools,
|
||||
web: {
|
||||
...config.tools?.web,
|
||||
search: {
|
||||
...config.tools?.web?.search,
|
||||
provider,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function preserveDisabledState(original: OpenClawConfig, result: OpenClawConfig): OpenClawConfig {
|
||||
if (original.tools?.web?.search?.enabled !== false) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
tools: {
|
||||
...result.tools,
|
||||
web: { ...result.tools?.web, search: { ...result.tools?.web?.search, enabled: false } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type SetupSearchOptions = {
|
||||
quickstartDefaults?: boolean;
|
||||
secretInputMode?: SecretInputMode;
|
||||
};
|
||||
|
||||
export async function setupSearch(
|
||||
config: OpenClawConfig,
|
||||
_runtime: RuntimeEnv,
|
||||
prompter: WizardPrompter,
|
||||
opts?: SetupSearchOptions,
|
||||
): Promise<OpenClawConfig> {
|
||||
await prompter.note(
|
||||
[
|
||||
"Web search lets your agent look things up online.",
|
||||
"Choose a provider and paste your API key.",
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
|
||||
const existingProvider = config.tools?.web?.search?.provider;
|
||||
|
||||
const options = SEARCH_PROVIDER_OPTIONS.map((entry) => {
|
||||
const configured = hasExistingKey(config, entry.value) || hasKeyInEnv(entry);
|
||||
const hint = configured ? `${entry.hint} · configured` : entry.hint;
|
||||
return { value: entry.value, label: entry.label, hint };
|
||||
});
|
||||
|
||||
const defaultProvider: SearchProvider = (() => {
|
||||
if (existingProvider && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === existingProvider)) {
|
||||
return existingProvider;
|
||||
}
|
||||
const detected = SEARCH_PROVIDER_OPTIONS.find(
|
||||
(e) => hasExistingKey(config, e.value) || hasKeyInEnv(e),
|
||||
);
|
||||
if (detected) {
|
||||
return detected.value;
|
||||
}
|
||||
return "perplexity";
|
||||
})();
|
||||
|
||||
type PickerValue = SearchProvider | "__skip__";
|
||||
const choice = await prompter.select<PickerValue>({
|
||||
message: "Search provider",
|
||||
options: [
|
||||
...options,
|
||||
{
|
||||
value: "__skip__" as const,
|
||||
label: "Skip for now",
|
||||
hint: "Configure later with openclaw configure --section web",
|
||||
},
|
||||
],
|
||||
initialValue: defaultProvider as PickerValue,
|
||||
});
|
||||
|
||||
if (choice === "__skip__") {
|
||||
return config;
|
||||
}
|
||||
|
||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === choice)!;
|
||||
const existingKey = resolveExistingKey(config, choice);
|
||||
const keyConfigured = hasExistingKey(config, choice);
|
||||
const envAvailable = hasKeyInEnv(entry);
|
||||
|
||||
if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) {
|
||||
const result = existingKey
|
||||
? applySearchKey(config, choice, existingKey)
|
||||
: applyProviderOnly(config, choice);
|
||||
return preserveDisabledState(config, result);
|
||||
}
|
||||
|
||||
if (opts?.secretInputMode === "ref") {
|
||||
if (keyConfigured) {
|
||||
return preserveDisabledState(config, applyProviderOnly(config, choice));
|
||||
}
|
||||
const ref = buildSearchEnvRef(choice);
|
||||
await prompter.note(
|
||||
[
|
||||
"Secret references enabled — OpenClaw will store a reference instead of the API key.",
|
||||
`Env var: ${ref.id}${envAvailable ? " (detected)" : ""}.`,
|
||||
...(envAvailable ? [] : [`Set ${ref.id} in the Gateway environment.`]),
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
return applySearchKey(config, choice, ref);
|
||||
}
|
||||
|
||||
const keyInput = await prompter.text({
|
||||
message: keyConfigured
|
||||
? `${entry.label} API key (leave blank to keep current)`
|
||||
: envAvailable
|
||||
? `${entry.label} API key (leave blank to use env var)`
|
||||
: `${entry.label} API key`,
|
||||
placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder,
|
||||
});
|
||||
|
||||
const key = keyInput?.trim() ?? "";
|
||||
if (key) {
|
||||
const secretInput = resolveSearchSecretInput(choice, key, opts?.secretInputMode);
|
||||
return applySearchKey(config, choice, secretInput);
|
||||
}
|
||||
|
||||
if (existingKey) {
|
||||
return preserveDisabledState(config, applySearchKey(config, choice, existingKey));
|
||||
}
|
||||
|
||||
if (keyConfigured || envAvailable) {
|
||||
return preserveDisabledState(config, applyProviderOnly(config, choice));
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
"No API key stored — web_search won't work until a key is available.",
|
||||
`Get your key at: ${entry.signupUrl}`,
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
|
||||
return {
|
||||
...config,
|
||||
tools: {
|
||||
...config.tools,
|
||||
web: {
|
||||
...config.tools?.web,
|
||||
search: {
|
||||
...config.tools?.web?.search,
|
||||
provider: choice,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -154,6 +154,7 @@ export type OnboardOptions = {
|
||||
/** @deprecated Legacy alias for `skipChannels`. */
|
||||
skipProviders?: boolean;
|
||||
skipSkills?: boolean;
|
||||
skipSearch?: boolean;
|
||||
skipHealth?: boolean;
|
||||
skipUi?: boolean;
|
||||
nodeManager?: NodeManagerChoice;
|
||||
|
||||
@@ -70,8 +70,8 @@ describe("web search provider auto-detection", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("falls back to brave when no keys available", () => {
|
||||
expect(resolveSearchProvider({})).toBe("brave");
|
||||
it("falls back to perplexity when no keys available", () => {
|
||||
expect(resolveSearchProvider({})).toBe("perplexity");
|
||||
});
|
||||
|
||||
it("auto-detects brave when only BRAVE_API_KEY is set", () => {
|
||||
@@ -109,19 +109,21 @@ describe("web search provider auto-detection", () => {
|
||||
expect(resolveSearchProvider({})).toBe("kimi");
|
||||
});
|
||||
|
||||
it("follows priority order — brave wins when multiple keys available", () => {
|
||||
it("follows priority order — perplexity wins when multiple keys available", () => {
|
||||
process.env.PERPLEXITY_API_KEY = "test-perplexity-key";
|
||||
process.env.BRAVE_API_KEY = "test-brave-key";
|
||||
process.env.GEMINI_API_KEY = "test-gemini-key";
|
||||
process.env.XAI_API_KEY = "test-xai-key";
|
||||
expect(resolveSearchProvider({})).toBe("perplexity");
|
||||
});
|
||||
|
||||
it("brave wins over gemini and grok when perplexity unavailable", () => {
|
||||
process.env.BRAVE_API_KEY = "test-brave-key";
|
||||
process.env.GEMINI_API_KEY = "test-gemini-key";
|
||||
process.env.XAI_API_KEY = "test-xai-key";
|
||||
expect(resolveSearchProvider({})).toBe("brave");
|
||||
});
|
||||
|
||||
it("gemini wins over perplexity and grok when brave unavailable", () => {
|
||||
process.env.GEMINI_API_KEY = "test-gemini-key";
|
||||
process.env.PERPLEXITY_API_KEY = "test-perplexity-key";
|
||||
expect(resolveSearchProvider({})).toBe("gemini");
|
||||
});
|
||||
|
||||
it("explicit provider always wins regardless of keys", () => {
|
||||
process.env.BRAVE_API_KEY = "test-brave-key";
|
||||
expect(
|
||||
|
||||
@@ -444,7 +444,7 @@ export type ToolsConfig = {
|
||||
/** Search provider ("brave", "perplexity", "grok", "gemini", or "kimi"). */
|
||||
provider?: "brave" | "perplexity" | "grok" | "gemini" | "kimi";
|
||||
/** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */
|
||||
apiKey?: string;
|
||||
apiKey?: SecretInput;
|
||||
/** Default search results count (1-10). */
|
||||
maxResults?: number;
|
||||
/** Timeout in seconds for search requests. */
|
||||
@@ -454,7 +454,7 @@ export type ToolsConfig = {
|
||||
/** Perplexity-specific configuration (used when provider="perplexity"). */
|
||||
perplexity?: {
|
||||
/** API key for Perplexity (defaults to PERPLEXITY_API_KEY env var). */
|
||||
apiKey?: string;
|
||||
apiKey?: SecretInput;
|
||||
/** @deprecated Legacy Sonar/OpenRouter field. Ignored by Search API. */
|
||||
baseUrl?: string;
|
||||
/** @deprecated Legacy Sonar/OpenRouter field. Ignored by Search API. */
|
||||
@@ -463,7 +463,7 @@ export type ToolsConfig = {
|
||||
/** Grok-specific configuration (used when provider="grok"). */
|
||||
grok?: {
|
||||
/** API key for xAI (defaults to XAI_API_KEY env var). */
|
||||
apiKey?: string;
|
||||
apiKey?: SecretInput;
|
||||
/** Model to use (defaults to "grok-4-1-fast"). */
|
||||
model?: string;
|
||||
/** Include inline citations in response text as markdown links (default: false). */
|
||||
@@ -472,14 +472,14 @@ export type ToolsConfig = {
|
||||
/** Gemini-specific configuration (used when provider="gemini"). */
|
||||
gemini?: {
|
||||
/** Gemini API key (defaults to GEMINI_API_KEY env var). */
|
||||
apiKey?: string;
|
||||
apiKey?: SecretInput;
|
||||
/** Model to use for grounded search (defaults to "gemini-2.5-flash"). */
|
||||
model?: string;
|
||||
};
|
||||
/** Kimi-specific configuration (used when provider="kimi"). */
|
||||
kimi?: {
|
||||
/** Moonshot/Kimi API key (defaults to KIMI_API_KEY or MOONSHOT_API_KEY env var). */
|
||||
apiKey?: string;
|
||||
apiKey?: SecretInput;
|
||||
/** Base URL for API requests (defaults to "https://api.moonshot.ai/v1"). */
|
||||
baseUrl?: string;
|
||||
/** Model to use (defaults to "moonshot-v1-128k"). */
|
||||
|
||||
@@ -472,39 +472,86 @@ export async function finalizeOnboardingWizard(
|
||||
);
|
||||
}
|
||||
|
||||
const webSearchProvider = nextConfig.tools?.web?.search?.provider ?? "brave";
|
||||
const webSearchKey =
|
||||
webSearchProvider === "perplexity"
|
||||
? (nextConfig.tools?.web?.search?.perplexity?.apiKey ?? "").trim()
|
||||
: (nextConfig.tools?.web?.search?.apiKey ?? "").trim();
|
||||
const webSearchEnv =
|
||||
webSearchProvider === "perplexity"
|
||||
? (process.env.PERPLEXITY_API_KEY ?? "").trim()
|
||||
: (process.env.BRAVE_API_KEY ?? "").trim();
|
||||
const hasWebSearchKey = Boolean(webSearchKey || webSearchEnv);
|
||||
await prompter.note(
|
||||
hasWebSearchKey
|
||||
? [
|
||||
const webSearchProvider = nextConfig.tools?.web?.search?.provider;
|
||||
const webSearchEnabled = nextConfig.tools?.web?.search?.enabled;
|
||||
if (webSearchProvider) {
|
||||
const { SEARCH_PROVIDER_OPTIONS, resolveExistingKey, hasExistingKey, hasKeyInEnv } =
|
||||
await import("../commands/onboard-search.js");
|
||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === webSearchProvider);
|
||||
const label = entry?.label ?? webSearchProvider;
|
||||
const storedKey = resolveExistingKey(nextConfig, webSearchProvider);
|
||||
const keyConfigured = hasExistingKey(nextConfig, webSearchProvider);
|
||||
const envAvailable = entry ? hasKeyInEnv(entry) : false;
|
||||
const hasKey = keyConfigured || envAvailable;
|
||||
const keySource = storedKey
|
||||
? "API key: stored in config."
|
||||
: keyConfigured
|
||||
? "API key: configured via secret reference."
|
||||
: envAvailable
|
||||
? `API key: provided via ${entry?.envKeys.join(" / ")} env var.`
|
||||
: undefined;
|
||||
if (webSearchEnabled !== false && hasKey) {
|
||||
await prompter.note(
|
||||
[
|
||||
"Web search is enabled, so your agent can look things up online when needed.",
|
||||
"",
|
||||
`Provider: ${webSearchProvider === "perplexity" ? "Perplexity Search" : "Brave Search"}`,
|
||||
webSearchKey
|
||||
? `API key: stored in config (tools.web.search.${webSearchProvider === "perplexity" ? "perplexity.apiKey" : "apiKey"}).`
|
||||
: `API key: provided via ${webSearchProvider === "perplexity" ? "PERPLEXITY_API_KEY" : "BRAVE_API_KEY"} env var (Gateway environment).`,
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n")
|
||||
: [
|
||||
"To enable web search, your agent will need an API key for either Perplexity Search or Brave Search.",
|
||||
"",
|
||||
"Set it up interactively:",
|
||||
`- Run: ${formatCliCommand("openclaw configure --section web")}`,
|
||||
"- Choose a provider and paste your API key",
|
||||
"",
|
||||
"Alternative: set PERPLEXITY_API_KEY or BRAVE_API_KEY in the Gateway environment (no config changes).",
|
||||
`Provider: ${label}`,
|
||||
...(keySource ? [keySource] : []),
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search (optional)",
|
||||
);
|
||||
"Web search",
|
||||
);
|
||||
} else if (!hasKey) {
|
||||
await prompter.note(
|
||||
[
|
||||
`Provider ${label} is selected but no API key was found.`,
|
||||
"web_search will not work until a key is added.",
|
||||
` ${formatCliCommand("openclaw configure --section web")}`,
|
||||
"",
|
||||
`Get your key at: ${entry?.signupUrl ?? "https://docs.openclaw.ai/tools/web"}`,
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
} else {
|
||||
await prompter.note(
|
||||
[
|
||||
`Web search (${label}) is configured but disabled.`,
|
||||
`Re-enable: ${formatCliCommand("openclaw configure --section web")}`,
|
||||
"",
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Legacy configs may have a working key (e.g. apiKey or BRAVE_API_KEY) without
|
||||
// an explicit provider. Runtime auto-detects these, so avoid saying "skipped".
|
||||
const { SEARCH_PROVIDER_OPTIONS, hasExistingKey, hasKeyInEnv } =
|
||||
await import("../commands/onboard-search.js");
|
||||
const legacyDetected = SEARCH_PROVIDER_OPTIONS.find(
|
||||
(e) => hasExistingKey(nextConfig, e.value) || hasKeyInEnv(e),
|
||||
);
|
||||
if (legacyDetected) {
|
||||
await prompter.note(
|
||||
[
|
||||
`Web search is available via ${legacyDetected.label} (auto-detected).`,
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
} else {
|
||||
await prompter.note(
|
||||
[
|
||||
"Web search was skipped. You can enable it later:",
|
||||
` ${formatCliCommand("openclaw configure --section web")}`,
|
||||
"",
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
'What now: https://openclaw.ai/showcase ("What People Are Building").',
|
||||
|
||||
@@ -31,8 +31,8 @@ const configureGatewayForOnboarding = vi.hoisted(() =>
|
||||
);
|
||||
const finalizeOnboardingWizard = vi.hoisted(() =>
|
||||
vi.fn(async (options) => {
|
||||
if (!process.env.BRAVE_API_KEY) {
|
||||
await options.prompter.note("hint", "Web search (optional)");
|
||||
if (!options.nextConfig?.tools?.web?.search?.provider) {
|
||||
await options.prompter.note("Web search was skipped.", "Web search");
|
||||
}
|
||||
|
||||
if (options.opts.skipUi) {
|
||||
@@ -263,6 +263,7 @@ describe("runOnboardingWizard", () => {
|
||||
installDaemon: false,
|
||||
skipProviders: true,
|
||||
skipSkills: true,
|
||||
skipSearch: true,
|
||||
skipHealth: true,
|
||||
skipUi: true,
|
||||
},
|
||||
@@ -291,6 +292,7 @@ describe("runOnboardingWizard", () => {
|
||||
installDaemon: false,
|
||||
skipProviders: true,
|
||||
skipSkills: true,
|
||||
skipSearch: true,
|
||||
skipHealth: true,
|
||||
skipUi: true,
|
||||
},
|
||||
@@ -335,6 +337,7 @@ describe("runOnboardingWizard", () => {
|
||||
authChoice: "skip",
|
||||
skipProviders: true,
|
||||
skipSkills: true,
|
||||
skipSearch: true,
|
||||
skipHealth: true,
|
||||
installDaemon: false,
|
||||
},
|
||||
@@ -375,6 +378,7 @@ describe("runOnboardingWizard", () => {
|
||||
installDaemon: false,
|
||||
skipProviders: true,
|
||||
skipSkills: true,
|
||||
skipSearch: true,
|
||||
skipHealth: true,
|
||||
skipUi: true,
|
||||
},
|
||||
@@ -384,7 +388,7 @@ describe("runOnboardingWizard", () => {
|
||||
|
||||
const calls = (note as unknown as { mock: { calls: unknown[][] } }).mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
expect(calls.some((call) => call?.[1] === "Web search (optional)")).toBe(true);
|
||||
expect(calls.some((call) => call?.[1] === "Web search")).toBe(true);
|
||||
} finally {
|
||||
if (prevBraveKey === undefined) {
|
||||
delete process.env.BRAVE_API_KEY;
|
||||
@@ -440,6 +444,7 @@ describe("runOnboardingWizard", () => {
|
||||
installDaemon: false,
|
||||
skipProviders: true,
|
||||
skipSkills: true,
|
||||
skipSearch: true,
|
||||
skipHealth: true,
|
||||
skipUi: true,
|
||||
},
|
||||
@@ -476,6 +481,7 @@ describe("runOnboardingWizard", () => {
|
||||
installDaemon: false,
|
||||
skipProviders: true,
|
||||
skipSkills: true,
|
||||
skipSearch: true,
|
||||
skipHealth: true,
|
||||
skipUi: true,
|
||||
secretInputMode: "ref",
|
||||
|
||||
@@ -512,6 +512,16 @@ export async function runOnboardingWizard(
|
||||
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
|
||||
});
|
||||
|
||||
if (opts.skipSearch) {
|
||||
await prompter.note("Skipping search setup.", "Search");
|
||||
} else {
|
||||
const { setupSearch } = await import("../commands/onboard-search.js");
|
||||
nextConfig = await setupSearch(nextConfig, runtime, prompter, {
|
||||
quickstartDefaults: flow === "quickstart",
|
||||
secretInputMode: opts.secretInputMode,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.skipSkills) {
|
||||
await prompter.note("Skipping skills setup.", "Skills");
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user