@zkov/pi-md-viewer 0.1.0

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,326 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createReadStream, readFileSync, statSync } from "node:fs";
3
+ import { createServer } from "node:http";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { ClientLifecycle } from "./lifecycle.js";
7
+ import { RenderError } from "./renderer.js";
8
+ const APPLICATION = "pi-md-viewer";
9
+ const PROTOCOL_VERSION = "1.0";
10
+ const MAX_REQUEST_BYTES = 256 * 1024;
11
+ const runtimeDir = path.dirname(fileURLToPath(import.meta.url));
12
+ const projectRoot = [path.resolve(runtimeDir, ".."), path.resolve(runtimeDir, "..", "..")]
13
+ .find((candidate) => {
14
+ try {
15
+ statSync(path.join(candidate, "src", "pi_md_viewer"));
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }) ?? path.resolve(runtimeDir, "..");
22
+ const legacyAssetRoot = path.join(projectRoot, "src", "pi_md_viewer");
23
+ export class ViewerServer {
24
+ registry;
25
+ renderer;
26
+ csrfToken = randomBytes(32).toString("base64url");
27
+ lifecycle = new ClientLifecycle(Date.now() / 1000);
28
+ server;
29
+ host;
30
+ requestedPort;
31
+ actualPort = 0;
32
+ monitorLifecycle;
33
+ monitor;
34
+ subscribers = new Set();
35
+ constructor(options) {
36
+ if (options.host !== "127.0.0.1")
37
+ throw new Error("ViewerServer only binds to 127.0.0.1");
38
+ this.host = options.host;
39
+ this.requestedPort = options.port;
40
+ this.registry = options.registry;
41
+ this.renderer = options.renderer;
42
+ this.monitorLifecycle = options.monitorLifecycle ?? true;
43
+ this.server = createServer((request, response) => {
44
+ void this.handle(request, response);
45
+ });
46
+ }
47
+ get port() {
48
+ return this.actualPort;
49
+ }
50
+ get url() {
51
+ return `http://127.0.0.1:${this.port}/`;
52
+ }
53
+ async start() {
54
+ await new Promise((resolve, reject) => {
55
+ this.server.once("error", reject);
56
+ this.server.listen(this.requestedPort, this.host, () => {
57
+ this.server.off("error", reject);
58
+ const address = this.server.address();
59
+ if (typeof address === "object" && address)
60
+ this.actualPort = address.port;
61
+ if (this.monitorLifecycle)
62
+ this.startMonitor();
63
+ resolve();
64
+ });
65
+ });
66
+ }
67
+ async stop() {
68
+ if (this.monitor) {
69
+ clearInterval(this.monitor);
70
+ this.monitor = undefined;
71
+ }
72
+ this.emit("server_closing");
73
+ for (const subscriber of this.subscribers) {
74
+ subscriber.end();
75
+ }
76
+ this.subscribers.clear();
77
+ await new Promise((resolve, reject) => {
78
+ if (!this.server.listening) {
79
+ resolve();
80
+ return;
81
+ }
82
+ this.server.close((error) => error ? reject(error) : resolve());
83
+ });
84
+ this.registry.clear();
85
+ }
86
+ async handle(request, response) {
87
+ if (!this.requestIsAllowed(request, response))
88
+ return;
89
+ const parsed = new URL(request.url ?? "/", this.url);
90
+ try {
91
+ if (request.method === "GET") {
92
+ await this.handleGet(parsed, request, response);
93
+ }
94
+ else if (request.method === "POST") {
95
+ await this.handlePost(parsed, request, response);
96
+ }
97
+ else if (request.method === "DELETE") {
98
+ await this.handleDelete(parsed, request, response);
99
+ }
100
+ else {
101
+ this.error(response, 405, "method_not_allowed", "Method not allowed");
102
+ }
103
+ }
104
+ catch (error) {
105
+ this.error(response, 500, "internal_error", error instanceof Error ? error.message : String(error));
106
+ }
107
+ }
108
+ requestIsAllowed(request, response) {
109
+ const remote = request.socket.remoteAddress;
110
+ if (remote && !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(remote)) {
111
+ this.error(response, 403, "forbidden", "Only loopback clients are allowed");
112
+ return false;
113
+ }
114
+ const host = String(request.headers.host ?? "");
115
+ const hostname = host.replace(/^\[/, "").replace(/\].*$/, "").split(":")[0]?.toLowerCase() ?? "";
116
+ if (!["127.0.0.1", "localhost", "::1"].includes(hostname)) {
117
+ this.error(response, 400, "invalid_host", "Invalid Host header");
118
+ return false;
119
+ }
120
+ return true;
121
+ }
122
+ async handleGet(parsed, request, response) {
123
+ if (parsed.pathname === "/") {
124
+ const template = readFileSync(path.join(legacyAssetRoot, "templates", "viewer.html"), "utf8");
125
+ this.sendBytes(response, 200, Buffer.from(template.replace("{{CSRF_TOKEN}}", this.csrfToken)), "text/html; charset=utf-8");
126
+ }
127
+ else if (["/static/viewer.css", "/static/viewer.js", "/static/viewer-actions.js"].includes(parsed.pathname)) {
128
+ await this.serveAsset(parsed.pathname.replace("/static/", ""), response);
129
+ }
130
+ else if (parsed.pathname === "/api/v1/status") {
131
+ this.sendJson(response, 200, {
132
+ application: APPLICATION,
133
+ protocolVersion: PROTOCOL_VERSION,
134
+ pid: process.pid,
135
+ url: this.url,
136
+ viewerClients: this.lifecycle.clientCount,
137
+ files: this.registry.list().length,
138
+ });
139
+ }
140
+ else if (parsed.pathname === "/api/v1/files") {
141
+ this.sendJson(response, 200, { files: this.registry.list().map(recordJson) });
142
+ }
143
+ else if (parsed.pathname === "/api/v1/events") {
144
+ this.handleEvents(parsed, response);
145
+ }
146
+ else if (parsed.pathname.startsWith("/api/v1/files/")) {
147
+ const id = parsed.pathname.split("/").pop() ?? "";
148
+ const record = this.registry.get(id);
149
+ if (!record) {
150
+ this.error(response, 404, "not_found", "File is not registered");
151
+ return;
152
+ }
153
+ try {
154
+ const rendered = this.renderer.render(record.path);
155
+ this.sendJson(response, 200, { file: recordJson(record), html: rendered.html, toc: rendered.toc, revision: rendered.revision });
156
+ }
157
+ catch (error) {
158
+ if (error instanceof RenderError)
159
+ this.error(response, 422, error.code, error.message);
160
+ else
161
+ throw error;
162
+ }
163
+ }
164
+ else {
165
+ this.error(response, 404, "not_found", "Endpoint not found");
166
+ }
167
+ }
168
+ async handlePost(parsed, request, response) {
169
+ if (parsed.pathname === "/api/v1/files") {
170
+ if (request.headers.origin || request.headers["x-pi-md-viewer-cli"] !== "1") {
171
+ this.error(response, 403, "forbidden", "CLI authorization required");
172
+ return;
173
+ }
174
+ const payload = await this.readJson(request);
175
+ const paths = payload.paths;
176
+ if (!Array.isArray(paths) || !paths.every((item) => typeof item === "string")) {
177
+ this.error(response, 400, "invalid_request", "paths must be an array of strings");
178
+ return;
179
+ }
180
+ const result = this.registry.addMany(paths);
181
+ const accepted = [...result.added, ...result.existing];
182
+ if (accepted.length > 0)
183
+ this.emit("files_changed");
184
+ this.sendJson(response, accepted.length > 0 ? 200 : 400, {
185
+ ok: accepted.length > 0,
186
+ added: result.added.map(recordJson),
187
+ existing: result.existing.map(recordJson),
188
+ rejected: result.rejected,
189
+ viewerClients: this.lifecycle.clientCount,
190
+ });
191
+ }
192
+ else if (parsed.pathname === "/api/v1/clients/open") {
193
+ if (!this.validCsrf(request, parsed)) {
194
+ this.error(response, 403, "forbidden", "Invalid CSRF token");
195
+ return;
196
+ }
197
+ const payload = await this.readJson(request);
198
+ if (typeof payload.clientId !== "string" || !payload.clientId) {
199
+ this.error(response, 400, "invalid_request", "clientId is required");
200
+ return;
201
+ }
202
+ this.lifecycle.open(payload.clientId, Date.now() / 1000);
203
+ this.sendJson(response, 200, { ok: true, clientId: payload.clientId });
204
+ }
205
+ else if (parsed.pathname === "/api/v1/clients/close") {
206
+ if (!this.validCsrf(request, parsed)) {
207
+ this.error(response, 403, "forbidden", "Invalid CSRF token");
208
+ return;
209
+ }
210
+ const payload = await this.readJson(request);
211
+ if (typeof payload.clientId !== "string" || !payload.clientId) {
212
+ this.error(response, 400, "invalid_request", "clientId is required");
213
+ return;
214
+ }
215
+ this.lifecycle.close(payload.clientId, Date.now() / 1000);
216
+ this.sendJson(response, 200, { ok: true, clientId: payload.clientId });
217
+ }
218
+ else if (parsed.pathname === "/api/v1/shutdown") {
219
+ if (request.headers["x-pi-md-viewer-cli"] !== "1") {
220
+ this.error(response, 403, "forbidden", "CLI authorization required");
221
+ return;
222
+ }
223
+ this.sendJson(response, 200, { ok: true });
224
+ setTimeout(() => { void this.stop(); }, 0);
225
+ }
226
+ else {
227
+ this.error(response, 404, "not_found", "Endpoint not found");
228
+ }
229
+ }
230
+ async handleDelete(parsed, request, response) {
231
+ if (!parsed.pathname.startsWith("/api/v1/files/")) {
232
+ this.error(response, 404, "not_found", "Endpoint not found");
233
+ return;
234
+ }
235
+ if (!this.validCsrf(request, parsed)) {
236
+ this.error(response, 403, "forbidden", "Invalid CSRF token");
237
+ return;
238
+ }
239
+ const id = parsed.pathname.split("/").pop() ?? "";
240
+ const removed = this.registry.remove(id);
241
+ if (!removed) {
242
+ this.error(response, 404, "not_found", "File is not registered");
243
+ return;
244
+ }
245
+ this.emit("file_removed");
246
+ this.sendJson(response, 200, { ok: true, removed: recordJson(removed) });
247
+ }
248
+ handleEvents(parsed, response) {
249
+ const clientId = parsed.searchParams.get("clientId");
250
+ if (clientId)
251
+ this.lifecycle.open(clientId, Date.now() / 1000);
252
+ response.writeHead(200, {
253
+ "Content-Type": "text/event-stream; charset=utf-8",
254
+ "Cache-Control": "no-cache",
255
+ Connection: "keep-alive",
256
+ });
257
+ response.write(": connected\n\n");
258
+ this.subscribers.add(response);
259
+ response.on("close", () => {
260
+ this.subscribers.delete(response);
261
+ if (clientId)
262
+ this.lifecycle.close(clientId, Date.now() / 1000);
263
+ });
264
+ }
265
+ emit(event) {
266
+ for (const subscriber of this.subscribers) {
267
+ subscriber.write(`event: ${event}\ndata: {}\n\n`);
268
+ }
269
+ }
270
+ validCsrf(request, parsed) {
271
+ return request.headers["x-csrf-token"] === this.csrfToken || parsed.searchParams.get("csrf") === this.csrfToken;
272
+ }
273
+ startMonitor() {
274
+ this.monitor ??= setInterval(() => {
275
+ if (this.lifecycle.decision(Date.now() / 1000).shouldShutdown) {
276
+ void this.stop();
277
+ }
278
+ }, 500);
279
+ }
280
+ async serveAsset(name, response) {
281
+ const file = path.join(legacyAssetRoot, "static", name);
282
+ try {
283
+ const stat = statSync(file);
284
+ const type = name.endsWith(".css") ? "text/css; charset=utf-8" : "application/javascript; charset=utf-8";
285
+ response.writeHead(200, { "Content-Type": type, "Content-Length": stat.size });
286
+ createReadStream(file).pipe(response);
287
+ }
288
+ catch {
289
+ this.error(response, 404, "not_found", "Asset not found");
290
+ }
291
+ }
292
+ async readJson(request) {
293
+ const chunks = [];
294
+ let size = 0;
295
+ for await (const chunk of request) {
296
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
297
+ size += buffer.length;
298
+ if (size > MAX_REQUEST_BYTES)
299
+ throw new Error("Request body is too large");
300
+ chunks.push(buffer);
301
+ }
302
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
303
+ }
304
+ sendJson(response, status, payload) {
305
+ const body = Buffer.from(JSON.stringify(payload));
306
+ this.sendBytes(response, status, body, "application/json; charset=utf-8");
307
+ }
308
+ sendBytes(response, status, body, contentType) {
309
+ response.writeHead(status, { "Content-Type": contentType, "Content-Length": body.length });
310
+ response.end(body);
311
+ }
312
+ error(response, status, code, message) {
313
+ this.sendJson(response, status, { error: { code, message } });
314
+ }
315
+ }
316
+ function recordJson(record) {
317
+ return {
318
+ id: record.id,
319
+ path: record.path,
320
+ displayName: record.displayName,
321
+ parentLabel: record.parentLabel,
322
+ size: record.size,
323
+ mtimeMs: record.mtimeMs,
324
+ order: record.order,
325
+ };
326
+ }
@@ -0,0 +1,5 @@
1
+ export const builtInDefaultConfig = {
2
+ defaultViewer: "system",
3
+ playwright: { browser: "chromium" },
4
+ viewers: {},
5
+ };
@@ -0,0 +1,31 @@
1
+ export function parseViewerCommand(value) {
2
+ const trimmed = value.trim();
3
+ if (!trimmed)
4
+ throw new Error("viewer command must be non-empty");
5
+ if (trimmed.startsWith("[")) {
6
+ let parsed;
7
+ try {
8
+ parsed = JSON.parse(trimmed);
9
+ }
10
+ catch (error) {
11
+ throw new Error(`viewer command JSON is invalid: ${error instanceof Error ? error.message : String(error)}`);
12
+ }
13
+ if (!Array.isArray(parsed) || parsed.length === 0) {
14
+ throw new Error("viewer command JSON must be a non-empty string array");
15
+ }
16
+ if (!parsed.every((item) => typeof item === "string")) {
17
+ throw new Error("viewer command JSON array must contain only strings");
18
+ }
19
+ return parsed;
20
+ }
21
+ return [trimmed];
22
+ }
23
+ export function expandViewerCommand(command, url) {
24
+ if (command.length === 0)
25
+ throw new Error("viewer command must be non-empty");
26
+ const expanded = command.map((item) => item.replaceAll("{url}", url));
27
+ if (!expanded.some((item) => item.includes(url))) {
28
+ expanded.push(url);
29
+ }
30
+ return expanded;
31
+ }
@@ -0,0 +1,107 @@
1
+ import { spawn } from "node:child_process";
2
+ import { headedPlaywrightUnavailableReason, systemOpenCommand } from "./platform.js";
3
+ import { expandViewerCommand, parseViewerCommand } from "./viewer-command.js";
4
+ async function defaultSpawnDetached(command, args) {
5
+ const child = spawn(command, args, {
6
+ detached: true,
7
+ stdio: "ignore",
8
+ windowsHide: true,
9
+ });
10
+ child.unref();
11
+ return { code: 0 };
12
+ }
13
+ async function launchCommand(command, url, spawnDetached) {
14
+ const expanded = expandViewerCommand(command, url);
15
+ const [executable, ...args] = expanded;
16
+ if (!executable)
17
+ throw new Error("viewer command must be non-empty");
18
+ const result = await spawnDetached(executable, args);
19
+ if (typeof result.code === "number" && result.code !== 0) {
20
+ throw new Error(`viewer command exited with status ${result.code}; open ${url} manually`);
21
+ }
22
+ }
23
+ function isMissingPlaywrightPackage(error) {
24
+ const candidate = error;
25
+ return candidate.code === "ERR_MODULE_NOT_FOUND"
26
+ || candidate.code === "MODULE_NOT_FOUND"
27
+ || candidate.message?.includes("Cannot find package 'playwright'") === true
28
+ || candidate.message?.includes('Cannot find module "playwright"') === true;
29
+ }
30
+ function isMissingPlaywrightBrowser(error) {
31
+ const message = error.message ?? "";
32
+ return message.includes("Executable doesn't exist")
33
+ || message.includes("Executable doesn't exist at")
34
+ || message.includes("browserType.launch: Executable")
35
+ || message.includes("Please run the following command to download new browsers");
36
+ }
37
+ function playwrightInstallHint(browserName) {
38
+ return `Viewer "playwright" requires Playwright. Install it with:\nnpm install playwright\nnpx playwright install ${browserName}`;
39
+ }
40
+ function playwrightBrowserInstallHint(browserName) {
41
+ return `Viewer "playwright" browser is not installed. Install it with:\nnpx playwright install ${browserName}`;
42
+ }
43
+ function playwrightLaunchOptions(browserName, platform, env) {
44
+ const options = { headless: false };
45
+ if (browserName === "chromium") {
46
+ options.args = ["--start-maximized"];
47
+ if (platform === "linux" && env.WAYLAND_DISPLAY && !env.DISPLAY) {
48
+ options.args.push("--ozone-platform=wayland", "--enable-features=UseOzonePlatform");
49
+ }
50
+ }
51
+ return options;
52
+ }
53
+ async function launchPlaywright(request) {
54
+ const reason = headedPlaywrightUnavailableReason(request.platform, request.env);
55
+ if (reason === "unsupported-platform")
56
+ throw new Error('Viewer "playwright" is not available on this platform');
57
+ if (reason)
58
+ throw new Error(`Viewer "playwright" is not available: ${reason}`);
59
+ const browserName = request.selection.config.playwright.browser;
60
+ let imported;
61
+ try {
62
+ imported = request.importPlaywright ? await request.importPlaywright() : await import("playwright");
63
+ }
64
+ catch (error) {
65
+ if (isMissingPlaywrightPackage(error))
66
+ throw new Error(playwrightInstallHint(browserName));
67
+ throw error;
68
+ }
69
+ const api = imported;
70
+ const browserType = api[browserName];
71
+ if (!browserType)
72
+ throw new Error(`Playwright browser "${browserName}" is unavailable`);
73
+ let browser;
74
+ try {
75
+ browser = await browserType.launch(playwrightLaunchOptions(browserName, request.platform, request.env));
76
+ }
77
+ catch (error) {
78
+ if (isMissingPlaywrightBrowser(error))
79
+ throw new Error(playwrightBrowserInstallHint(browserName));
80
+ throw error;
81
+ }
82
+ const context = await browser.newContext({ viewport: null });
83
+ const page = await context.newPage();
84
+ await page.goto(request.url);
85
+ }
86
+ export async function launchViewer(request) {
87
+ const spawnDetached = request.spawnDetached ?? defaultSpawnDetached;
88
+ if (request.selection.viewerCommand) {
89
+ await launchCommand(parseViewerCommand(request.selection.viewerCommand), request.url, spawnDetached);
90
+ return;
91
+ }
92
+ if (request.selection.viewer === "system") {
93
+ const command = systemOpenCommand(request.platform, request.env);
94
+ if (!command)
95
+ throw new Error(`No system opener is available; open ${request.url} manually`);
96
+ await launchCommand(command, request.url, spawnDetached);
97
+ return;
98
+ }
99
+ if (request.selection.viewer === "playwright") {
100
+ await launchPlaywright(request);
101
+ return;
102
+ }
103
+ const named = request.selection.config.viewers[request.selection.viewer];
104
+ if (!named)
105
+ throw new Error(`Viewer "${request.selection.viewer}" is not configured`);
106
+ await launchCommand(named.command, request.url, spawnDetached);
107
+ }
@@ -0,0 +1,159 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Type, type Static } from "typebox";
6
+
7
+ const showMarkdownSchema = Type.Object({
8
+ paths: Type.Array(Type.String({ description: "Markdown path, absolute or relative to the current project" }), {
9
+ minItems: 1,
10
+ description: "One or more Markdown files to show in the local browser viewer",
11
+ }),
12
+ open: Type.Optional(Type.Boolean({
13
+ description: "Open the browser when no viewer page is connected (default: true)",
14
+ })),
15
+ viewer: Type.Optional(Type.String({
16
+ description: "Viewer strategy: system, playwright, or a named viewer from config",
17
+ })),
18
+ viewerCommand: Type.Optional(Type.String({
19
+ description: "Ad-hoc viewer command: plain executable or JSON argv array with optional {url}",
20
+ })),
21
+ });
22
+
23
+ export type ShowMarkdownInput = Static<typeof showMarkdownSchema>;
24
+
25
+ export interface ViewerResult {
26
+ ok: boolean;
27
+ action: string;
28
+ url: string | null;
29
+ added: string[];
30
+ rejected: Array<{ path: string; code: string; message: string }>;
31
+ viewerClients: number;
32
+ }
33
+
34
+ export interface Invocation {
35
+ command: string;
36
+ args: string[];
37
+ }
38
+
39
+ export interface InvocationOptions {
40
+ platform?: NodeJS.Platform;
41
+ projectTrusted?: boolean;
42
+ }
43
+
44
+ export function buildInvocation(
45
+ cwd: string,
46
+ extensionFile: string,
47
+ input: ShowMarkdownInput,
48
+ options: InvocationOptions = {},
49
+ ): Invocation {
50
+ if (input.paths.length === 0) {
51
+ throw new Error("show_markdown requires at least one path");
52
+ }
53
+ const platform = options.platform ?? process.platform;
54
+ const pathForCwd = platform === "win32" ? path.win32 : path.posix;
55
+ const extensionPath = fileURLToPath(extensionFile);
56
+ const launcher = path.resolve(path.dirname(extensionPath), "..", "bin", "mdview.js");
57
+ const resolvedPaths = input.paths.map((supplied) => {
58
+ const normalized = supplied.startsWith("@") ? supplied.slice(1) : supplied;
59
+ if (!normalized) throw new Error("show_markdown path cannot be empty");
60
+ return pathForCwd.resolve(cwd, normalized);
61
+ });
62
+ const args = [launcher, "--json", "--launched-from-pi", "--cwd", cwd];
63
+ if (options.projectTrusted) args.push("--project-trusted");
64
+ if (input.open === false) args.push("--no-open");
65
+ if (input.viewer) args.push("--viewer", input.viewer);
66
+ if (input.viewerCommand) args.push("--viewer-command", input.viewerCommand);
67
+ args.push(...resolvedPaths);
68
+ return { command: process.execPath, args };
69
+ }
70
+
71
+ export function parseViewerResult(stdout: string): ViewerResult {
72
+ let value: unknown;
73
+ try {
74
+ value = JSON.parse(stdout);
75
+ } catch (error) {
76
+ throw new Error(`Viewer returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
77
+ }
78
+ if (!isViewerResult(value)) {
79
+ throw new Error("Viewer returned an invalid result object");
80
+ }
81
+ return value;
82
+ }
83
+
84
+ function isViewerResult(value: unknown): value is ViewerResult {
85
+ if (typeof value !== "object" || value === null) return false;
86
+ const candidate = value as Record<string, unknown>;
87
+ return typeof candidate.ok === "boolean"
88
+ && typeof candidate.action === "string"
89
+ && (typeof candidate.url === "string" || candidate.url === null)
90
+ && Array.isArray(candidate.added)
91
+ && candidate.added.every((item) => typeof item === "string")
92
+ && Array.isArray(candidate.rejected)
93
+ && candidate.rejected.every(isRejectedPath)
94
+ && typeof candidate.viewerClients === "number";
95
+ }
96
+
97
+ function isRejectedPath(value: unknown): boolean {
98
+ if (typeof value !== "object" || value === null) return false;
99
+ const candidate = value as Record<string, unknown>;
100
+ return typeof candidate.path === "string"
101
+ && typeof candidate.code === "string"
102
+ && typeof candidate.message === "string";
103
+ }
104
+
105
+ function safeError(stderr: string, stdout: string): string {
106
+ const diagnostic = stderr.trim();
107
+ if (diagnostic) return diagnostic.slice(0, 4096);
108
+ const output = stdout.trim();
109
+ if (!output) return "mdview failed without diagnostic output";
110
+ try {
111
+ const parsed = JSON.parse(output) as { message?: unknown; error?: { message?: unknown } };
112
+ const message = parsed.message ?? parsed.error?.message;
113
+ if (typeof message === "string" && message.trim()) return message.trim().slice(0, 4096);
114
+ } catch {
115
+ // Fall back to the bounded raw output below.
116
+ }
117
+ return output.slice(0, 4096);
118
+ }
119
+
120
+ function formatResult(result: ViewerResult): string {
121
+ const pieces = [`${result.action}: ${result.added.length} Markdown file(s)`];
122
+ if (result.url) pieces.push(result.url);
123
+ if (result.rejected.length > 0) {
124
+ pieces.push(`${result.rejected.length} rejected: ${result.rejected.map((item) => item.path).join(", ")}`);
125
+ }
126
+ return pieces.join("\n");
127
+ }
128
+
129
+ export default function registerExtension(pi: ExtensionAPI): void {
130
+ pi.registerTool({
131
+ name: "show_markdown",
132
+ label: "Show Markdown",
133
+ description: "Open one or more local Markdown files in the local browser viewer. Reuses the active viewer and returns a loopback URL. Does not install dependencies.",
134
+ promptSnippet: "Open local Markdown files in a responsive browser viewer",
135
+ promptGuidelines: [
136
+ "Use show_markdown when the user asks to show, demonstrate, preview, or conveniently open one or more Markdown documents.",
137
+ ],
138
+ parameters: showMarkdownSchema,
139
+ async execute(_toolCallId, input, signal, _onUpdate, ctx) {
140
+ const projectTrusted = typeof ctx.isProjectTrusted === "function" ? ctx.isProjectTrusted() : false;
141
+ const invocation = buildInvocation(ctx.cwd, import.meta.url, input, { projectTrusted });
142
+ const child = await pi.exec(invocation.command, invocation.args, {
143
+ signal,
144
+ timeout: 15_000,
145
+ });
146
+ if (child.code !== 0) {
147
+ throw new Error(safeError(child.stderr, child.stdout));
148
+ }
149
+ const result = parseViewerResult(child.stdout);
150
+ if (!result.ok) {
151
+ throw new Error(formatResult(result));
152
+ }
153
+ return {
154
+ content: [{ type: "text", text: formatResult(result) }],
155
+ details: result,
156
+ };
157
+ },
158
+ });
159
+ }