1import { readFile } from "node:fs/promises";
2import { basename } from "node:path";
3import { expandHomePath } from "../../util/path.js";
4import { applyConfigOverrides, loadConfig } from "../../config/loader.js";
5import { createWorkspace } from "../../workspace/manager.js";
6import { writeWorkspaceFile } from "../../workspace/content.js";
7import { createGrepTool } from "../../agent/tools/grep.js";
8import { createReadTool } from "../../agent/tools/read.js";
9import { createLsTool } from "../../agent/tools/ls.js";
10import { createFindTool } from "../../agent/tools/find.js";
11import { createWebFetchTool } from "../../agent/tools/web-fetch.js";
12import { createWebSearchTool } from "../../agent/tools/web-search.js";
13import { runAgent } from "../../agent/runner.js";
14import { buildWebPrompt } from "../../agent/prompts/web.js";
15import { createEventLogger, printUsageSummary } from "../output.js";
16import { ConfigError, FetchError } from "../../util/errors.js";
17
18const INJECT_THRESHOLD = 50 * 1024;
19
20export interface WebCommandOptions {
21 query: string;
22 url?: string;
23 model?: string;
24 verbose: boolean;
25 cleanup: boolean;
26}
27
28export async function runWebCommand(options: WebCommandOptions): Promise<void> {
29 const { config } = await loadConfig();
30 const overrides = applyConfigOverrides(config, {
31 defaults: { model: options.model ?? config.defaults.model, cleanup: options.cleanup },
32 web: { model: options.model ?? config.web.model },
33 });
34
35 const workspace = await createWorkspace({ cleanup: overrides.defaults.cleanup });
36
37 try {
38 const logger = createEventLogger({ verbose: options.verbose });
39
40 const kagiSession =
41 overrides.web.kagi_session_token ?? overrides.defaults.kagi_session_token ?? process.env["KAGI_SESSION_TOKEN"];
42 const tabstackKey =
43 overrides.web.tabstack_api_key ??
44 overrides.defaults.tabstack_api_key ??
45 process.env["TABSTACK_API_KEY"];
46
47 if (!kagiSession) {
48 throw new ConfigError("Web search requires KAGI_SESSION_TOKEN (set via environment or config)");
49 }
50 if (!tabstackKey) {
51 throw new ConfigError("Web fetch requires TABSTACK_API_KEY (set via environment or config)");
52 }
53
54 let systemPrompt = buildWebPrompt({
55 currentTime: new Date().toISOString(),
56 });
57 const promptPath = overrides.web.system_prompt_path;
58 if (promptPath) {
59 systemPrompt = await readFile(expandHomePath(promptPath), "utf8");
60 }
61
62 const tools = [
63 createWebSearchTool(kagiSession),
64 createWebFetchTool(tabstackKey),
65 createReadTool(workspace.path),
66 createGrepTool(workspace.path),
67 createLsTool(workspace.path),
68 createFindTool(workspace.path),
69 ];
70
71 let seededContext = "";
72 if (options.url) {
73 const fetchTool = createWebFetchTool(tabstackKey);
74 try {
75 const result = await fetchTool.execute("prefetch", { url: options.url, nocache: false });
76 const text = result.content
77 .map((block) => (block.type === "text" ? block.text ?? "" : ""))
78 .join("");
79 if (text.length <= INJECT_THRESHOLD) {
80 seededContext = text;
81 } else {
82 const filename = `web/${basename(new URL(options.url).pathname) || "index"}.md`;
83 await writeWorkspaceFile(workspace.path, filename, text);
84 seededContext = `Content from ${options.url} was too large to include directly. It has been saved to ${filename} in your workspace.`;
85 }
86 } catch (error: any) {
87 throw new FetchError(options.url, error?.message ?? String(error));
88 }
89 }
90
91 const query = seededContext
92 ? `${options.query}\n\n<attached_content url="${options.url}">\n${seededContext}\n</attached_content>`
93 : options.query;
94
95 const result = await runAgent(query, {
96 model: overrides.web.model ?? overrides.defaults.model,
97 systemPrompt,
98 tools,
99 onEvent: logger,
100 config: overrides,
101 });
102
103 process.stdout.write(result.message + "\n");
104 printUsageSummary(result.usage as any, result.requestCount);
105 } finally {
106 await workspace.cleanup();
107 }
108}