@danypops/pi-web-spider 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Cache path tests — local materialized view behaviour.
3
+ *
4
+ * Two paths in execute() that were previously uncovered:
5
+ *
6
+ * 1. Cache hit — when a URL has already been fetched in this session,
7
+ * web_fetch(url) returns the cached page without hitting
8
+ * the network again.
9
+ *
10
+ * 2. Cache search — web_fetch({ query }) with no url searches all cached
11
+ * pages using BM25F and returns ranked hits.
12
+ *
13
+ * Both paths use a single harness session so the cache accumulates state
14
+ * across calls as it would in a real Pi session.
15
+ */
16
+
17
+ import { readFileSync } from "node:fs"
18
+ import { dirname, join } from "node:path"
19
+ import { fileURLToPath } from "node:url"
20
+ import {
21
+ afterAll,
22
+ beforeAll,
23
+ beforeEach,
24
+ describe,
25
+ expect,
26
+ it,
27
+ vi,
28
+ } from "vitest"
29
+ import {
30
+ createExtensionHarness,
31
+ type ExtensionHarness,
32
+ } from "@earendil-works/pi-coding-agent/testing"
33
+
34
+ const __dirname = dirname(fileURLToPath(import.meta.url))
35
+ const FIXTURES = join(__dirname, "../../web-spider/fixtures")
36
+ const ARTICLE_HTML = readFileSync(join(FIXTURES, "article-with-images.html"), "utf8")
37
+
38
+ const URL_A = "https://example.com/article-a"
39
+ const URL_B = "https://example.com/article-b"
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Helpers
43
+ // ---------------------------------------------------------------------------
44
+
45
+ function makeOkResponse(html: string) {
46
+ return {
47
+ ok: true,
48
+ status: 200,
49
+ statusText: "OK",
50
+ headers: { get: () => null },
51
+ text: async () => html,
52
+ arrayBuffer: async () => new ArrayBuffer(0),
53
+ }
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Single shared session — cache builds up across tests
58
+ // ---------------------------------------------------------------------------
59
+
60
+ let h: ExtensionHarness
61
+ let fetchMock: ReturnType<typeof vi.fn>
62
+
63
+ beforeAll(async () => {
64
+ const { default: factory } = await import("../src/index.js")
65
+ h = createExtensionHarness(factory, {
66
+ cwd: "/tmp",
67
+ env: { WEB_SPIDER_CACHE_PATH: "/tmp/ws-cache-paths-test.json" },
68
+ })
69
+ await h.boot()
70
+ })
71
+
72
+ afterAll(async () => {
73
+ await h.shutdown()
74
+ })
75
+
76
+ beforeEach(() => {
77
+ fetchMock = vi.fn(async (input: RequestInfo | URL) => {
78
+ const url = input.toString()
79
+ if (url.includes("robots.txt")) return makeOkResponse("User-agent: *\nAllow: /")
80
+ if (url.includes("sitemap")) return { ...makeOkResponse(""), status: 404, ok: false }
81
+ if (url.startsWith("https://example.com")) return makeOkResponse(ARTICLE_HTML)
82
+ return { ...makeOkResponse(""), status: 404, ok: false }
83
+ })
84
+ vi.stubGlobal("fetch", fetchMock)
85
+ })
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Cache listing — no url, no query
89
+ // ---------------------------------------------------------------------------
90
+
91
+ describe("cache listing path", () => {
92
+ it("returns total=0 and empty pages on a cold cache", async () => {
93
+ // Fresh harness in beforeAll, no fetches yet.
94
+ const result = await h.invokeTool("web_fetch", {}) as { content: { text: string }[] }
95
+ const text = JSON.parse(result.content[0].text)
96
+
97
+ expect(text).not.toHaveProperty("error")
98
+ expect(text).toHaveProperty("total")
99
+ expect(text).toHaveProperty("pages")
100
+ expect(Array.isArray(text.pages)).toBe(true)
101
+ })
102
+ })
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Cache hit — second fetch for the same URL skips the network
106
+ // ---------------------------------------------------------------------------
107
+
108
+ describe("cache hit path", () => {
109
+ it("fetches URL_A on first call", async () => {
110
+ const result = await h.invokeTool("web_fetch", {
111
+ url: URL_A,
112
+ format: "lean",
113
+ }) as { content: { text: string }[] }
114
+ const text = JSON.parse(result.content[0].text)
115
+
116
+ expect(text).not.toHaveProperty("error")
117
+ expect(text.wordCount).toBeGreaterThan(0)
118
+ })
119
+
120
+ it("returns the cached page on second call without hitting the network", async () => {
121
+ // Reset the mock so we can assert it was NOT called again.
122
+ fetchMock.mockClear()
123
+
124
+ const result = await h.invokeTool("web_fetch", {
125
+ url: URL_A,
126
+ format: "lean",
127
+ }) as { content: { text: string }[] }
128
+ const text = JSON.parse(result.content[0].text)
129
+
130
+ expect(text).not.toHaveProperty("error")
131
+ expect(text.wordCount).toBeGreaterThan(0)
132
+
133
+ // Network was not touched for the main URL — only robots.txt may fire.
134
+ const mainUrlCalls = fetchMock.mock.calls.filter(
135
+ ([input]: [RequestInfo | URL]) => input.toString().startsWith(URL_A)
136
+ )
137
+ expect(mainUrlCalls).toHaveLength(0)
138
+ })
139
+
140
+ it("cache listing shows URL_A after it has been fetched", async () => {
141
+ const result = await h.invokeTool("web_fetch", {}) as { content: { text: string }[] }
142
+ const text = JSON.parse(result.content[0].text)
143
+
144
+ expect(text.total).toBeGreaterThanOrEqual(1)
145
+ const urls = text.pages.map((p: { url: string }) => p.url)
146
+ expect(urls).toContain(URL_A)
147
+ })
148
+ })
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Cache search — no url, with query
152
+ // ---------------------------------------------------------------------------
153
+
154
+ describe("cache search path", () => {
155
+ it("fetches URL_B to populate the cache", async () => {
156
+ const result = await h.invokeTool("web_fetch", {
157
+ url: URL_B,
158
+ format: "lean",
159
+ }) as { content: { text: string }[] }
160
+ const text = JSON.parse(result.content[0].text)
161
+ expect(text).not.toHaveProperty("error")
162
+ })
163
+
164
+ it("returns BM25F hits when query matches cached content", async () => {
165
+ const result = await h.invokeTool("web_fetch", {
166
+ query: "image scraping web spider",
167
+ }) as { content: { text: string }[] }
168
+ const text = JSON.parse(result.content[0].text)
169
+
170
+ expect(text).not.toHaveProperty("error")
171
+ expect(text).toHaveProperty("hits")
172
+ expect(Array.isArray(text.hits)).toBe(true)
173
+ expect(text.hits.length).toBeGreaterThan(0)
174
+ expect(text).toHaveProperty("pagesSearched")
175
+ expect(text.pagesSearched).toBeGreaterThanOrEqual(1)
176
+ })
177
+
178
+ it("each hit has url, score, and text", async () => {
179
+ const result = await h.invokeTool("web_fetch", {
180
+ query: "image scraping",
181
+ }) as { content: { text: string }[] }
182
+ const text = JSON.parse(result.content[0].text)
183
+ const hit = text.hits[0]
184
+
185
+ expect(hit).toHaveProperty("url")
186
+ expect(hit).toHaveProperty("score")
187
+ expect(typeof hit.score).toBe("number")
188
+ })
189
+
190
+ it("returns zero hits gracefully for a query with no matches", async () => {
191
+ const result = await h.invokeTool("web_fetch", {
192
+ // Use a single token with no vowels — avoids the hyphen-splitting bug where
193
+ // "xyzzy-no-such-content-ever-12345" tokenises to [xyzzy, no, such, content,
194
+ // ever, 12345] and "content" literally matches the article fixture.
195
+ query: "zxqfkwjpvm",
196
+ }) as { content: { text: string }[] }
197
+ const text = JSON.parse(result.content[0].text)
198
+
199
+ expect(text).not.toHaveProperty("error")
200
+ expect(text).toHaveProperty("hits")
201
+ expect(text.hits).toHaveLength(0)
202
+ })
203
+
204
+ it("grep= filter narrows cache listing results", async () => {
205
+ const result = await h.invokeTool("web_fetch", {
206
+ grep: "article-a",
207
+ }) as { content: { text: string }[] }
208
+ const text = JSON.parse(result.content[0].text)
209
+
210
+ expect(text).not.toHaveProperty("error")
211
+ expect(text.pages.every((p: { url: string }) => p.url.includes("article-a"))).toBe(true)
212
+ })
213
+ })
@@ -0,0 +1,210 @@
1
+ /**
2
+ * E2E tests using the production jiti load path (tryNative:true + alias map).
3
+ *
4
+ * vi.mock() operates in vitest's module registry, which the extension factory
5
+ * bypasses via dynamic import(). These tests run the extension in a subprocess
6
+ * via mock-pi-cli.mjs so the real jiti context is exercised end-to-end.
7
+ */
8
+
9
+ import { readFileSync, mkdirSync } from "node:fs"
10
+ import { spawn } from "node:child_process"
11
+ import { resolve, dirname } from "node:path"
12
+ import { fileURLToPath } from "node:url"
13
+ import { describe, expect, it } from "vitest"
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url))
16
+ const CLI = resolve(__dirname, "fixtures/mock-pi-cli.mjs")
17
+ const EXTENSION = resolve(__dirname, "../src/index.ts")
18
+ const CACHE_DIR = "/tmp/ws-e2e-jiti"
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Helper — run mock-pi-cli and collect emitted NDJSON events
22
+ // ---------------------------------------------------------------------------
23
+ interface Event { type: string; [k: string]: unknown }
24
+
25
+ interface RunResult {
26
+ events: Event[]
27
+ diagPath: string
28
+ }
29
+
30
+ function runCli(opts: {
31
+ tool?: string
32
+ /** Single invocation. Mutually exclusive with paramsList. */
33
+ params?: Record<string, unknown>
34
+ /** Multiple sequential invocations in the same process — same extension
35
+ * instance, same cache object. Use for cache round-trip tests. */
36
+ paramsList?: Record<string, unknown>[]
37
+ env?: Record<string, string>
38
+ timeoutMs?: number
39
+ }): Promise<RunResult> {
40
+ return new Promise((resolve, reject) => {
41
+ mkdirSync(CACHE_DIR, { recursive: true })
42
+ const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`
43
+ // Unique cache per run — prevents cross-test hits from masking errors.
44
+ const cachePath = `${CACHE_DIR}/cache-${tag}.json`
45
+ // Unique diag path — lets tests read and assert the boot probe.
46
+ const diagPath = `${CACHE_DIR}/diag-${tag}.log`
47
+
48
+ const invocations = opts.paramsList ?? [opts.params ?? {}]
49
+
50
+ const args = [
51
+ "--extension", EXTENSION,
52
+ "--tool", opts.tool ?? "web_fetch",
53
+ // Each element becomes a separate --params flag; mock-pi-cli.mjs
54
+ // invokes the tool once per flag in the same process.
55
+ ...invocations.flatMap(p => ["--params", JSON.stringify(p)]),
56
+ "--env", `WEB_SPIDER_CACHE_PATH=${cachePath}`,
57
+ "--env", `WEB_SPIDER_DIAG_PATH=${diagPath}`,
58
+ ...Object.entries(opts.env ?? {}).flatMap(([k, v]) => ["--env", `${k}=${v}`]),
59
+ ]
60
+
61
+ const proc = spawn("node", [CLI, ...args], {
62
+ env: { ...process.env, ...opts.env },
63
+ stdio: ["ignore", "pipe", "pipe"],
64
+ })
65
+
66
+ const lines: string[] = []
67
+ const stderr: string[] = []
68
+
69
+ proc.stdout.on("data", (chunk: Buffer) => lines.push(...chunk.toString().split("\n").filter(Boolean)))
70
+ proc.stderr.on("data", (chunk: Buffer) => stderr.push(chunk.toString()))
71
+
72
+ const timer = setTimeout(() => {
73
+ proc.kill("SIGKILL")
74
+ reject(new Error(`mock-pi-cli timed out after ${opts.timeoutMs ?? 15000}ms`))
75
+ }, opts.timeoutMs ?? 15000)
76
+
77
+ proc.on("close", () => {
78
+ clearTimeout(timer)
79
+ try {
80
+ resolve({ events: lines.map(l => JSON.parse(l) as Event), diagPath })
81
+ } catch (e) {
82
+ reject(new Error(`failed to parse NDJSON: ${e}\nlines: ${lines.join("\n")}\nstderr: ${stderr.join("")}`))
83
+ }
84
+ })
85
+ })
86
+ }
87
+
88
+ /** Read and parse the boot-probe line from a diag log written by the extension. */
89
+ function readBootProbe(diagPath: string): Record<string, unknown> | null {
90
+ let raw: string
91
+ try { raw = readFileSync(diagPath, "utf8") } catch { return null }
92
+ for (const line of raw.split("\n")) {
93
+ try {
94
+ const obj = JSON.parse(line) as Record<string, unknown>
95
+ if (obj["tag"] === "boot-probe") return obj
96
+ } catch { /* skip malformed lines */ }
97
+ }
98
+ return null
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Tests
103
+ // ---------------------------------------------------------------------------
104
+
105
+ describe("E2E: production jiti load (tryNative:true + alias)", () => {
106
+
107
+ it("extension loads and tool is registered", async () => {
108
+ const { events } = await runCli({
109
+ params: { url: "https://example.com", format: "lean" },
110
+ timeoutMs: 20000,
111
+ })
112
+
113
+ const start = events.find(e => e.type === "tool_execution_start")
114
+ const end = events.find(e => e.type === "tool_execution_end")
115
+
116
+ expect(start).toBeDefined()
117
+ expect(end).toBeDefined()
118
+
119
+ const result = (end as { result: { content: { text: string }[] } }).result
120
+ const text = JSON.parse(result.content[0].text)
121
+
122
+ expect(text).not.toHaveProperty("error")
123
+ expect(text).toHaveProperty("title")
124
+ }, 25000)
125
+
126
+ it("Playwright error propagates as { error } — auto-fallback path", async () => {
127
+ // enhanced:true forces Playwright regardless of jsRendered, exercising the
128
+ // same error-propagation code path as the jsRendered auto-fallback.
129
+ // GitHub's server-side HTML now has enough content for Readability (wordCount>0)
130
+ // so jsRendered stays false on plain fetch — enhanced:true is the reliable trigger.
131
+ const { events } = await runCli({
132
+ params: { url: "https://example.com", enhanced: true },
133
+ env: { WEB_SPIDER_PLAYWRIGHT_EXECUTABLE: "/nonexistent" },
134
+ timeoutMs: 20000,
135
+ })
136
+
137
+ const end = events.find(e => e.type === "tool_execution_end")
138
+ expect(end).toBeDefined()
139
+
140
+ const result = (end as { result: { content: { text: string }[] } }).result
141
+ const text = JSON.parse(result.content[0].text)
142
+
143
+ expect(text).toHaveProperty("error")
144
+ expect(typeof text.error).toBe("string")
145
+ expect(text.error).toMatch(/executable|launch|nonexistent/i)
146
+ }, 25000)
147
+
148
+ it("enhanced=true with missing binary returns { error } immediately", async () => {
149
+ const { events } = await runCli({
150
+ params: { url: "https://example.com", enhanced: true },
151
+ env: { WEB_SPIDER_PLAYWRIGHT_EXECUTABLE: "/nonexistent" },
152
+ timeoutMs: 20000,
153
+ })
154
+
155
+ const end = events.find(e => e.type === "tool_execution_end")
156
+ expect(end).toBeDefined()
157
+
158
+ const result = (end as { result: { content: { text: string }[] } }).result
159
+ const text = JSON.parse(result.content[0].text)
160
+
161
+ expect(text).toHaveProperty("error")
162
+ }, 25000)
163
+
164
+ it("process exits cleanly — does not hang", async () => {
165
+ const { events } = await runCli({
166
+ params: { url: "https://github.com/hyprwm/aquamarine/issues" },
167
+ env: { WEB_SPIDER_PLAYWRIGHT_EXECUTABLE: "/nonexistent" },
168
+ timeoutMs: 20000, // would hit this if the process hangs
169
+ })
170
+
171
+ const exit = events.find(e => e.type === "exit")
172
+ expect(exit).toBeDefined()
173
+ }, 25000)
174
+
175
+ it("cache round-trip: second fetch hits in-memory cache — no Map errors", async () => {
176
+ const { events } = await runCli({
177
+ paramsList: [
178
+ { url: "https://example.com", format: "lean" },
179
+ { url: "https://example.com", format: "lean" },
180
+ ],
181
+ timeoutMs: 25000,
182
+ })
183
+
184
+ const ends = events.filter(e => e.type === "tool_execution_end")
185
+ expect(ends).toHaveLength(2)
186
+
187
+ for (const end of ends) {
188
+ const text = JSON.parse((end as { result: { content: { text: string }[] } }).result.content[0].text)
189
+ expect(text).not.toHaveProperty("error")
190
+ expect(text).toHaveProperty("title")
191
+ }
192
+
193
+ const exit = events.find(e => e.type === "exit")
194
+ expect(exit).toBeDefined()
195
+ expect((exit as { code: number }).code).toBe(0)
196
+ }, 30000)
197
+
198
+ it("boot probe: storeIsMap===false and cacheClass===DiskCache (realm-safe storage)", async () => {
199
+ const { diagPath } = await runCli({
200
+ params: { url: "https://example.com", format: "lean" },
201
+ timeoutMs: 25000,
202
+ })
203
+
204
+ const probe = readBootProbe(diagPath)
205
+ expect(probe).not.toBeNull()
206
+ expect(probe!["cacheClass"]).toBe("DiskCache")
207
+ expect(probe!["storeIsMap"]).toBe(false)
208
+ expect(probe!["storeTag"]).toBe("[object Object]")
209
+ }, 30000)
210
+ })
@@ -0,0 +1,69 @@
1
+ /**
2
+ * execute() contract: never throw, always return content.
3
+ * Unhandled exceptions propagate to the Pi TUI and crash the session.
4
+ */
5
+
6
+ import { describe, it, expect, beforeAll, afterAll } from "vitest"
7
+ import { createExtensionHarness, type ExtensionHarness } from "@earendil-works/pi-coding-agent/testing"
8
+ import piFactory from "../src/index.js"
9
+
10
+ let h: ExtensionHarness
11
+
12
+ beforeAll(async () => {
13
+ h = createExtensionHarness(piFactory, { cwd: "/tmp" })
14
+ await h.boot()
15
+ })
16
+
17
+ afterAll(async () => {
18
+ await h.shutdown()
19
+ })
20
+
21
+ describe("stream hygiene: extension must not write to stdout or stderr", () => {
22
+ it("boot produces no stdout/stderr leaks", () => {
23
+ expect(h.leaks).toHaveLength(0)
24
+ })
25
+ })
26
+
27
+ describe("execute() error contract: never throws, always returns content", () => {
28
+ it("invalid URL scheme returns error, does not throw", async () => {
29
+ const result = await h.invokeTool("web_fetch", { url: "ftp://not-supported.example.com" }) as any
30
+ expect(result).toHaveProperty("content")
31
+ const text = JSON.parse(result.content[0].text)
32
+ expect(text).toHaveProperty("error")
33
+ expect(typeof text.error).toBe("string")
34
+ })
35
+
36
+ it("unreachable host returns error, does not throw", async () => {
37
+ const result = await h.invokeTool("web_fetch", {
38
+ url: "http://this-host-does-not-exist-pivi-test.invalid",
39
+ timeoutMs: 3000,
40
+ }) as any
41
+ expect(result).toHaveProperty("content")
42
+ const text = JSON.parse(result.content[0].text)
43
+ expect(text).toHaveProperty("error")
44
+ })
45
+
46
+ it("missing url returns cache listing (not error), does not throw", async () => {
47
+ // No url → local materialized view path: returns cache listing, not an error.
48
+ // This is intentional: omitting url is the way to query the session cache.
49
+ const result = await h.invokeTool("web_fetch", {}) as any
50
+ expect(result).toHaveProperty("content")
51
+ const text = JSON.parse(result.content[0].text)
52
+ expect(text).not.toHaveProperty("error")
53
+ expect(text).toHaveProperty("total")
54
+ expect(typeof text.total).toBe("number")
55
+ expect(text).toHaveProperty("pages")
56
+ expect(Array.isArray(text.pages)).toBe(true)
57
+ })
58
+
59
+ it("highlights without query returns error, does not throw", async () => {
60
+ const result = await h.invokeTool("web_fetch", {
61
+ url: "http://127.0.0.1:1",
62
+ format: "highlights",
63
+ }) as any
64
+ expect(result).toHaveProperty("content")
65
+ const text = JSON.parse(result.content[0].text)
66
+ expect(text).toHaveProperty("error")
67
+ })
68
+
69
+ })
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mock-pi-cli.mjs — minimal Pi CLI stub for E2E integration tests.
4
+ *
5
+ * Replicates the production jiti load path (alias + tryNative:true, NOT
6
+ * tryNative:false as the test harness uses). This is the critical difference
7
+ * that lets us verify error propagation in the real runtime context.
8
+ *
9
+ * Accepts Pi CLI args subset:
10
+ * --extension <path> extension entry point to load
11
+ * --env KEY=VALUE environment overrides (repeatable)
12
+ * --tool <name> tool to invoke (default: web_fetch)
13
+ * --params <json> tool params as JSON string
14
+ *
15
+ * Emits NDJSON events on stdout matching Pi's --mode json format:
16
+ * { type: "tool_execution_start", toolName, args }
17
+ * { type: "tool_execution_end", toolName, result }
18
+ * { type: "exit", code }
19
+ */
20
+
21
+ import { createJiti } from "jiti";
22
+ import { fileURLToPath } from "node:url";
23
+ import { resolve, dirname } from "node:path";
24
+ import { createRequire } from "node:module";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Parse args
28
+ // ---------------------------------------------------------------------------
29
+ const args = process.argv.slice(2);
30
+ const get = (flag) => { const i = args.indexOf(flag); return i !== -1 ? args[i + 1] : null; };
31
+ const getAll = (flag) => {
32
+ const vals = [];
33
+ for (let i = 0; i < args.length - 1; i++) if (args[i] === flag) vals.push(args[i + 1]);
34
+ return vals;
35
+ };
36
+
37
+ const extensionPath = get("--extension");
38
+ const toolName = get("--tool") ?? "web_fetch";
39
+ // Support multiple --params flags: each is one sequential tool invocation in the
40
+ // same process (same extension instance, same cache object). This is essential for
41
+ // cache round-trip tests where the second call must cross the realm boundary into
42
+ // an already-populated in-memory cache.
43
+ const allParamsJson = getAll("--params");
44
+ if (allParamsJson.length === 0) allParamsJson.push("{}");
45
+ const envOverrides = Object.fromEntries(getAll("--env").map(s => s.split("=", 2)));
46
+
47
+ if (!extensionPath) { process.stderr.write("--extension required\n"); process.exit(1); }
48
+
49
+ for (const [k, v] of Object.entries(envOverrides)) process.env[k] = v;
50
+
51
+ // tryNative:true (default) — matches the real Pi Node.js binary.
52
+ // The test harness uses tryNative:false, which breaks vi.mock interception.
53
+ const __dirname = dirname(fileURLToPath(import.meta.url));
54
+ const piDistRoot = resolve(__dirname, "../../../../../pi-mono/packages/coding-agent/dist");
55
+ const require = createRequire(import.meta.url);
56
+
57
+ const alias = {
58
+ "@earendil-works/pi-coding-agent": resolve(piDistRoot, "index.js"),
59
+ "@earendil-works/pi-agent-core": resolve(piDistRoot, "../../agent/dist/index.js"),
60
+ "@earendil-works/pi-tui": resolve(piDistRoot, "../../tui/dist/index.js"),
61
+ "@earendil-works/pi-ai": resolve(piDistRoot, "../../ai/dist/index.js"),
62
+ "typebox": require.resolve("typebox"),
63
+ };
64
+
65
+ const jiti = createJiti(import.meta.url, { moduleCache: false, alias });
66
+
67
+
68
+ const tools = new Map();
69
+
70
+ const pi = {
71
+ registerTool({ name, execute }) { tools.set(name, execute); },
72
+ on() {},
73
+ registerCommand() {},
74
+ ui: { notify() {} },
75
+ };
76
+
77
+ let factory;
78
+ try {
79
+ factory = await jiti.import(resolve(extensionPath), { default: true });
80
+ } catch (err) {
81
+ process.stderr.write(`[mock-pi-cli] load error: ${err.message}\n`);
82
+ process.exit(1);
83
+ }
84
+
85
+ if (typeof factory !== "function") {
86
+ process.stderr.write(`[mock-pi-cli] extension did not export a default function\n`);
87
+ process.exit(1);
88
+ }
89
+
90
+ await factory(pi);
91
+
92
+ const execute = tools.get(toolName);
93
+ if (!execute) {
94
+ process.stderr.write(`[mock-pi-cli] tool not found: ${toolName}\n`);
95
+ process.exit(1);
96
+ }
97
+
98
+ for (const paramsJson of allParamsJson) {
99
+ const params = JSON.parse(paramsJson);
100
+ emit({ type: "tool_execution_start", toolName, args: params });
101
+ try {
102
+ const result = await execute(`mock-call-${Date.now()}`, params);
103
+ emit({ type: "tool_execution_end", toolName, result });
104
+ } catch (err) {
105
+ emit({ type: "tool_execution_error", toolName, error: err?.message ?? String(err) });
106
+ emit({ type: "exit", code: 1 });
107
+ process.exit(1);
108
+ }
109
+ }
110
+
111
+ emit({ type: "exit", code: 0 });
112
+
113
+ function emit(obj) { process.stdout.write(JSON.stringify(obj) + "\n"); }