@oh-my-pi/pi-coding-agent 15.5.6 → 15.5.8

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
  3. package/dist/types/commands/auth-gateway.d.ts +3 -0
  4. package/dist/types/config/settings-schema.d.ts +60 -12
  5. package/dist/types/edit/file-snapshot-store.d.ts +9 -6
  6. package/dist/types/edit/hashline/diff.d.ts +4 -5
  7. package/dist/types/edit/streaming.d.ts +2 -1
  8. package/dist/types/eval/py/index.d.ts +1 -0
  9. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  10. package/dist/types/extensibility/shared-events.d.ts +1 -1
  11. package/dist/types/internal-urls/index.d.ts +1 -0
  12. package/dist/types/internal-urls/vault-protocol.d.ts +93 -0
  13. package/dist/types/lib/xai-http.d.ts +40 -0
  14. package/dist/types/mcp/transports/http.d.ts +9 -0
  15. package/dist/types/modes/components/tool-execution.d.ts +2 -1
  16. package/dist/types/session/agent-session.d.ts +4 -1
  17. package/dist/types/tools/fetch.d.ts +16 -0
  18. package/dist/types/tools/image-gen.d.ts +6 -2
  19. package/dist/types/tools/index.d.ts +1 -0
  20. package/dist/types/tools/match-line-format.d.ts +2 -2
  21. package/dist/types/tools/plan-mode-guard.d.ts +5 -6
  22. package/dist/types/tools/render-utils.d.ts +3 -1
  23. package/dist/types/tools/tts.d.ts +18 -0
  24. package/dist/types/tools/write.d.ts +2 -0
  25. package/dist/types/utils/file-mentions.d.ts +2 -0
  26. package/package.json +8 -8
  27. package/src/cli/args.ts +2 -0
  28. package/src/cli/auth-broker-cli.ts +2 -1
  29. package/src/cli/auth-gateway-cli.ts +210 -9
  30. package/src/commands/auth-gateway.ts +7 -1
  31. package/src/config/model-registry.ts +41 -9
  32. package/src/config/settings-schema.ts +55 -13
  33. package/src/edit/file-snapshot-store.ts +9 -6
  34. package/src/edit/hashline/diff.ts +26 -13
  35. package/src/edit/hashline/execute.ts +13 -9
  36. package/src/edit/renderer.ts +9 -9
  37. package/src/edit/streaming.ts +4 -6
  38. package/src/eval/py/index.ts +1 -1
  39. package/src/extensibility/custom-tools/types.ts +1 -1
  40. package/src/extensibility/shared-events.ts +1 -1
  41. package/src/internal-urls/docs-index.generated.ts +7 -7
  42. package/src/internal-urls/index.ts +1 -0
  43. package/src/internal-urls/router.ts +2 -0
  44. package/src/internal-urls/vault-protocol.ts +936 -0
  45. package/src/lib/xai-http.ts +124 -0
  46. package/src/main.ts +1 -2
  47. package/src/mcp/transports/http.ts +29 -2
  48. package/src/modes/components/tool-execution.ts +6 -4
  49. package/src/modes/controllers/event-controller.ts +10 -3
  50. package/src/modes/controllers/selector-controller.ts +7 -2
  51. package/src/modes/interactive-mode.ts +11 -3
  52. package/src/modes/utils/ui-helpers.ts +2 -1
  53. package/src/prompts/system/system-prompt.md +3 -0
  54. package/src/prompts/tools/ast-edit.md +1 -1
  55. package/src/prompts/tools/ast-grep.md +1 -1
  56. package/src/prompts/tools/read.md +3 -3
  57. package/src/prompts/tools/search.md +1 -1
  58. package/src/sdk.ts +41 -10
  59. package/src/session/agent-session.ts +112 -14
  60. package/src/system-prompt.ts +2 -0
  61. package/src/tools/ast-edit.ts +10 -7
  62. package/src/tools/ast-grep.ts +12 -11
  63. package/src/tools/eval.ts +28 -3
  64. package/src/tools/fetch.ts +52 -24
  65. package/src/tools/image-gen.ts +205 -7
  66. package/src/tools/index.ts +1 -0
  67. package/src/tools/match-line-format.ts +2 -2
  68. package/src/tools/path-utils.ts +2 -0
  69. package/src/tools/plan-mode-guard.ts +20 -7
  70. package/src/tools/read.ts +70 -55
  71. package/src/tools/render-utils.ts +15 -0
  72. package/src/tools/search.ts +14 -14
  73. package/src/tools/tts.ts +133 -0
  74. package/src/tools/write.ts +61 -6
  75. package/src/utils/file-mentions.ts +11 -5
  76. package/src/web/search/providers/codex.ts +2 -1
@@ -0,0 +1,936 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { $which, isEnoent } from "@oh-my-pi/pi-utils";
4
+ import { isSettingsInitialized, settings } from "../config/settings";
5
+ import { getDefault } from "../config/settings-schema";
6
+ import { parseInternalUrl } from "./parse";
7
+ import { validateRelativePath } from "./skill-protocol";
8
+ import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, WriteContext } from "./types";
9
+
10
+ const DARWIN_OBSIDIAN_BINARY = "/Applications/Obsidian.app/Contents/MacOS/obsidian";
11
+ const DEFAULT_OBSIDIAN_TIMEOUT_MS = 30_000;
12
+
13
+ type ContentType = InternalResource["contentType"];
14
+ type VaultParamValue = string | true;
15
+ type VaultParams = Record<string, VaultParamValue>;
16
+
17
+ type FileOp = "outline" | "backlinks" | "links" | "tags" | "properties" | "tasks" | "wordcount" | "history" | "base";
18
+
19
+ type VaultOp =
20
+ | "search"
21
+ | "daily"
22
+ | "daily-path"
23
+ | "tags"
24
+ | "tag"
25
+ | "tasks"
26
+ | "orphans"
27
+ | "unresolved"
28
+ | "deadends"
29
+ | "bases"
30
+ | "bookmarks"
31
+ | "recents"
32
+ | "templates"
33
+ | "aliases"
34
+ | "properties"
35
+ | "property";
36
+
37
+ const FILE_OPS: Record<FileOp, true> = {
38
+ outline: true,
39
+ backlinks: true,
40
+ links: true,
41
+ tags: true,
42
+ properties: true,
43
+ tasks: true,
44
+ wordcount: true,
45
+ history: true,
46
+ base: true,
47
+ };
48
+
49
+ const VAULT_OPS: Record<VaultOp, true> = {
50
+ search: true,
51
+ daily: true,
52
+ "daily-path": true,
53
+ tags: true,
54
+ tag: true,
55
+ tasks: true,
56
+ orphans: true,
57
+ unresolved: true,
58
+ deadends: true,
59
+ bases: true,
60
+ bookmarks: true,
61
+ recents: true,
62
+ templates: true,
63
+ aliases: true,
64
+ properties: true,
65
+ property: true,
66
+ };
67
+
68
+ export interface VaultReference {
69
+ vault: string | null;
70
+ active: boolean;
71
+ forwardVault: boolean;
72
+ display: string;
73
+ }
74
+
75
+ export type ParsedVaultUrl =
76
+ | { kind: "list-vaults"; url: string; params: VaultParams }
77
+ | { kind: "vault-info"; url: string; ref: VaultReference; params: VaultParams }
78
+ | { kind: "fs-dir"; url: string; ref: VaultReference; relativePath: string; params: VaultParams }
79
+ | { kind: "fs-file"; url: string; ref: VaultReference; relativePath: string; params: VaultParams }
80
+ | { kind: "file-op"; url: string; ref: VaultReference; relativePath: string; op: FileOp; params: VaultParams }
81
+ | { kind: "vault-op"; url: string; ref: VaultReference; op: VaultOp; params: VaultParams };
82
+
83
+ export interface ObsidianSpawnResult {
84
+ stdout: string;
85
+ stderr: string;
86
+ exitCode: number;
87
+ }
88
+
89
+ export interface VaultProtocolHandlerOptions {
90
+ spawnObsidian?: typeof spawnObsidian;
91
+ resolveObsidianBinary?: () => string | null;
92
+ }
93
+
94
+ interface CliInvocation {
95
+ args: string[];
96
+ contentType: ContentType;
97
+ opLabel: string;
98
+ }
99
+
100
+ interface VaultCounts {
101
+ files: number;
102
+ folders: number;
103
+ }
104
+
105
+ let cachedObsidianBinary: string | null | undefined;
106
+ let binaryOverrideForTests: string | null | undefined;
107
+ let cachedVaultDirectory: Map<string, string> | undefined;
108
+ let cachedActiveVaultPath: string | undefined;
109
+ const cachedVaultInfo = new Map<string, string>();
110
+
111
+ function toVaultValidationError(error: unknown): Error {
112
+ const message = error instanceof Error ? error.message : String(error);
113
+ return new Error(message.replace("skill://", "vault://"));
114
+ }
115
+
116
+ function getContentType(filePath: string): ContentType {
117
+ const ext = path.extname(filePath).toLowerCase();
118
+ if (ext === ".md") return "text/markdown";
119
+ if (ext === ".json") return "application/json";
120
+ return "text/plain";
121
+ }
122
+
123
+ function ensureWithinRoot(targetPath: string, rootPath: string): void {
124
+ if (targetPath !== rootPath && !targetPath.startsWith(`${rootPath}${path.sep}`)) {
125
+ throw new Error("vault:// URL escapes vault root");
126
+ }
127
+ }
128
+
129
+ function encodePathComponent(component: string): string {
130
+ return encodeURIComponent(component).replaceAll("%2F", "/");
131
+ }
132
+
133
+ function encodeRelativePath(relativePath: string): string {
134
+ return relativePath
135
+ .split("/")
136
+ .filter(segment => segment.length > 0)
137
+ .map(encodeURIComponent)
138
+ .join("/");
139
+ }
140
+
141
+ function decodeVaultPath(url: InternalUrl): {
142
+ rawPathname: string;
143
+ relativePath: string;
144
+ hasPath: boolean;
145
+ isDirectory: boolean;
146
+ } {
147
+ const rawPathname = url.rawPathname ?? url.pathname;
148
+ const hasPath = rawPathname !== undefined && rawPathname !== "" && rawPathname !== "/";
149
+ const isDirectory = rawPathname === "/" || rawPathname.endsWith("/");
150
+ if (!hasPath) {
151
+ return { rawPathname, relativePath: "", hasPath: false, isDirectory };
152
+ }
153
+
154
+ let decoded: string;
155
+ try {
156
+ decoded = decodeURIComponent(rawPathname.slice(1).replaceAll("\\", "/"));
157
+ } catch {
158
+ throw new Error(`Invalid URL encoding in vault:// path: ${url.href}`);
159
+ }
160
+
161
+ try {
162
+ validateRelativePath(decoded);
163
+ } catch (error) {
164
+ throw toVaultValidationError(error);
165
+ }
166
+
167
+ return { rawPathname, relativePath: decoded.replace(/\/+$/, ""), hasPath: true, isDirectory };
168
+ }
169
+
170
+ function paramsFromUrl(url: InternalUrl): VaultParams {
171
+ const params: VaultParams = {};
172
+ for (const [key, value] of url.searchParams) {
173
+ params[key] = value === "" ? true : value;
174
+ }
175
+ return params;
176
+ }
177
+
178
+ function makeVaultReference(host: string): VaultReference {
179
+ if (!host || host === "_") {
180
+ return { vault: null, active: true, forwardVault: false, display: "_" };
181
+ }
182
+ return { vault: host, active: false, forwardVault: true, display: host };
183
+ }
184
+
185
+ function isFileOp(rawOp: string): rawOp is FileOp {
186
+ return FILE_OPS[rawOp as FileOp] === true;
187
+ }
188
+
189
+ function isVaultOp(rawOp: string): rawOp is VaultOp {
190
+ return VAULT_OPS[rawOp as VaultOp] === true;
191
+ }
192
+
193
+ function parseVaultOp(rawOp: string, hasFilePath: boolean): FileOp | VaultOp {
194
+ if (hasFilePath) {
195
+ if (!isFileOp(rawOp)) {
196
+ throw new Error(`Unsupported vault:// file op: ${rawOp}`);
197
+ }
198
+ return rawOp;
199
+ }
200
+ if (!isVaultOp(rawOp)) {
201
+ throw new Error(`Unsupported vault:// vault op: ${rawOp}`);
202
+ }
203
+ return rawOp;
204
+ }
205
+
206
+ export function parseVaultUrl(input: string | InternalUrl): ParsedVaultUrl {
207
+ const url = typeof input === "string" ? parseInternalUrl(input) : input;
208
+ const host = url.rawHost || url.hostname;
209
+ const params = paramsFromUrl(url);
210
+ const rawOp = typeof params.op === "string" ? params.op : undefined;
211
+ const { rawPathname, relativePath, hasPath, isDirectory } = decodeVaultPath(url);
212
+
213
+ if (!host && !hasPath && !rawOp) {
214
+ return { kind: "list-vaults", url: url.href, params };
215
+ }
216
+
217
+ const ref = makeVaultReference(host);
218
+ if (rawOp) {
219
+ const op = parseVaultOp(rawOp, relativePath.length > 0);
220
+ if (relativePath.length > 0) {
221
+ return { kind: "file-op", url: url.href, ref, relativePath, op: op as FileOp, params };
222
+ }
223
+ return { kind: "vault-op", url: url.href, ref, op: op as VaultOp, params };
224
+ }
225
+
226
+ if (!host && !hasPath) {
227
+ return { kind: "list-vaults", url: url.href, params };
228
+ }
229
+
230
+ if (!hasPath && rawPathname !== "/") {
231
+ return { kind: "vault-info", url: url.href, ref, params };
232
+ }
233
+
234
+ if (isDirectory) {
235
+ return { kind: "fs-dir", url: url.href, ref, relativePath, params };
236
+ }
237
+
238
+ return { kind: "fs-file", url: url.href, ref, relativePath, params };
239
+ }
240
+
241
+ function abortError(): Error {
242
+ return new Error("obsidian command cancelled");
243
+ }
244
+
245
+ export async function spawnObsidian(
246
+ bin: string,
247
+ args: string[],
248
+ signal?: AbortSignal,
249
+ timeoutMs = DEFAULT_OBSIDIAN_TIMEOUT_MS,
250
+ ): Promise<ObsidianSpawnResult> {
251
+ if (signal?.aborted) throw abortError();
252
+
253
+ const proc = Bun.spawn({
254
+ cmd: [bin, ...args],
255
+ stdout: "pipe",
256
+ stderr: "pipe",
257
+ });
258
+ const stdout = new Response(proc.stdout as ReadableStream<Uint8Array>).text();
259
+ const stderr = new Response(proc.stderr as ReadableStream<Uint8Array>).text();
260
+ const aborted = Promise.withResolvers<never>();
261
+ const timedOut = Promise.withResolvers<never>();
262
+
263
+ const abortHandler = (): void => {
264
+ proc.kill();
265
+ aborted.reject(abortError());
266
+ };
267
+ if (signal) signal.addEventListener("abort", abortHandler, { once: true });
268
+
269
+ const timeout = setTimeout(() => {
270
+ proc.kill();
271
+ timedOut.reject(new Error(`obsidian command timed out after ${timeoutMs}ms`));
272
+ }, timeoutMs);
273
+
274
+ const completed = proc.exited.then(async exitCode => ({
275
+ stdout: await stdout,
276
+ stderr: await stderr,
277
+ exitCode,
278
+ }));
279
+
280
+ try {
281
+ return await Promise.race([completed, aborted.promise, timedOut.promise]);
282
+ } finally {
283
+ clearTimeout(timeout);
284
+ if (signal) signal.removeEventListener("abort", abortHandler);
285
+ }
286
+ }
287
+
288
+ export function resolveObsidianBinary(): string | null {
289
+ if (binaryOverrideForTests !== undefined) return binaryOverrideForTests;
290
+ if (cachedObsidianBinary !== undefined) return cachedObsidianBinary;
291
+
292
+ const onPath = $which("obsidian");
293
+ if (onPath) {
294
+ cachedObsidianBinary = onPath;
295
+ return cachedObsidianBinary;
296
+ }
297
+
298
+ if (process.platform === "darwin" && fs.existsSync(DARWIN_OBSIDIAN_BINARY)) {
299
+ cachedObsidianBinary = DARWIN_OBSIDIAN_BINARY;
300
+ return cachedObsidianBinary;
301
+ }
302
+
303
+ cachedObsidianBinary = null;
304
+ return cachedObsidianBinary;
305
+ }
306
+
307
+ /**
308
+ * Whether the `vault://` protocol is enabled in the active settings profile.
309
+ *
310
+ * Reads `vault.enabled` from the global settings singleton. Falls back to the
311
+ * schema default when settings are not yet initialized (e.g. during isolated
312
+ * unit tests that exercise the handler before the host calls `Settings.init`).
313
+ */
314
+ export function isVaultEnabled(): boolean {
315
+ if (!isSettingsInitialized()) return getDefault("vault.enabled");
316
+ try {
317
+ return settings.get("vault.enabled");
318
+ } catch {
319
+ // Defensive: if the settings proxy throws (e.g. shutdown race), fall back to default.
320
+ return getDefault("vault.enabled");
321
+ }
322
+ }
323
+
324
+ export function hasObsidian(): boolean {
325
+ return isVaultEnabled() && resolveObsidianBinary() !== null;
326
+ }
327
+
328
+ const VAULT_DISABLED_MESSAGE =
329
+ "vault:// is disabled. Enable it by setting `vault.enabled = true` (Settings → Tools → Obsidian Vault).";
330
+
331
+ export class VaultDisabledError extends Error {
332
+ constructor() {
333
+ super(VAULT_DISABLED_MESSAGE);
334
+ this.name = "VaultDisabledError";
335
+ }
336
+ }
337
+
338
+ function missingBinaryError(): Error {
339
+ return new Error(
340
+ "Obsidian CLI binary not found. Checked PATH entry 'obsidian' and " +
341
+ `${DARWIN_OBSIDIAN_BINARY}. Install Obsidian from https://obsidian.md or add its CLI binary to PATH.`,
342
+ );
343
+ }
344
+
345
+ function requireObsidianBinary(resolveBinary: () => string | null): string {
346
+ const bin = resolveBinary();
347
+ if (bin) return bin;
348
+ throw missingBinaryError();
349
+ }
350
+
351
+ function cliReportedError(result: ObsidianSpawnResult): string | undefined {
352
+ const stderr = result.stderr.trim();
353
+ if (stderr.startsWith("Error:")) return stderr;
354
+ const stdout = result.stdout.trim();
355
+ if (stdout.startsWith("Error:")) return stdout;
356
+ return undefined;
357
+ }
358
+
359
+ function assertCliSuccess(opLabel: string, result: ObsidianSpawnResult): void {
360
+ const reportedError = cliReportedError(result);
361
+ if (result.exitCode === 0 && !reportedError) return;
362
+ const stderr = result.stderr.trim();
363
+ const stdout = result.stdout.trim();
364
+ const detail = reportedError || stderr || stdout || `obsidian exited with code ${result.exitCode}`;
365
+ throw new Error(`vault://${opLabel} failed: ${detail}`);
366
+ }
367
+
368
+ function parseVaultDirectory(stdout: string): Map<string, string> {
369
+ const vaults = new Map<string, string>();
370
+ for (const line of stdout.split(/\r?\n/)) {
371
+ const trimmed = line.trimEnd();
372
+ if (!trimmed) continue;
373
+ const tab = trimmed.indexOf("\t");
374
+ if (tab <= 0) continue;
375
+ const name = trimmed.slice(0, tab);
376
+ const vaultPath = trimmed.slice(tab + 1).trim();
377
+ if (!name || !vaultPath) continue;
378
+ vaults.set(name, path.resolve(vaultPath));
379
+ }
380
+ return vaults;
381
+ }
382
+
383
+ function parseActiveVaultPath(stdout: string): string {
384
+ for (const line of stdout.split(/\r?\n/)) {
385
+ const trimmed = line.trim();
386
+ if (!trimmed) continue;
387
+ const tab = trimmed.indexOf("\t");
388
+ if (tab > 0 && trimmed.slice(0, tab).toLowerCase() === "path") {
389
+ return trimmed.slice(tab + 1).trim();
390
+ }
391
+ const colon = trimmed.indexOf(":");
392
+ if (colon > 0 && trimmed.slice(0, colon).toLowerCase() === "path") {
393
+ return trimmed.slice(colon + 1).trim();
394
+ }
395
+ }
396
+ const trimmed = stdout.trim();
397
+ return trimmed.includes("\n") ? "" : trimmed;
398
+ }
399
+
400
+ function getCachedVaultRoot(ref: VaultReference): string | undefined {
401
+ if (ref.active) return cachedActiveVaultPath ? path.resolve(cachedActiveVaultPath) : undefined;
402
+ if (!ref.vault) return undefined;
403
+ const cached = cachedVaultDirectory?.get(ref.vault);
404
+ return cached ? path.resolve(cached) : undefined;
405
+ }
406
+
407
+ function findExistingAncestorSync(targetPath: string, rootPath: string): string {
408
+ let current = targetPath;
409
+ while (true) {
410
+ ensureWithinRoot(current, rootPath);
411
+ try {
412
+ return fs.realpathSync(current);
413
+ } catch (error) {
414
+ if (!isEnoent(error)) throw error;
415
+ const parent = path.dirname(current);
416
+ if (parent === current) throw error;
417
+ current = parent;
418
+ }
419
+ }
420
+ }
421
+
422
+ export function resolveVaultUrlToPath(input: string | InternalUrl): string {
423
+ if (!isVaultEnabled()) throw new VaultDisabledError();
424
+ const parsed = parseVaultUrl(input);
425
+ if (parsed.kind !== "fs-file" && parsed.kind !== "fs-dir") {
426
+ throw new Error("vault:// path resolution only supports plain filesystem paths");
427
+ }
428
+
429
+ const cachedRoot = getCachedVaultRoot(parsed.ref);
430
+ if (!cachedRoot) {
431
+ throw new Error(
432
+ "vault:// path resolution requires a cached vault root; read vault:// first or use the write tool",
433
+ );
434
+ }
435
+
436
+ const resolvedRoot = fs.realpathSync(cachedRoot);
437
+ const targetPath = parsed.relativePath ? path.resolve(resolvedRoot, parsed.relativePath) : resolvedRoot;
438
+ ensureWithinRoot(targetPath, resolvedRoot);
439
+
440
+ try {
441
+ const realTarget = fs.realpathSync(targetPath);
442
+ ensureWithinRoot(realTarget, resolvedRoot);
443
+ } catch (error) {
444
+ if (!isEnoent(error)) throw error;
445
+ const realParent = findExistingAncestorSync(path.dirname(targetPath), resolvedRoot);
446
+ ensureWithinRoot(realParent, resolvedRoot);
447
+ }
448
+
449
+ return targetPath;
450
+ }
451
+
452
+ async function findExistingAncestor(targetPath: string, rootPath: string): Promise<string> {
453
+ let current = targetPath;
454
+ while (true) {
455
+ ensureWithinRoot(current, rootPath);
456
+ try {
457
+ return await fs.promises.realpath(current);
458
+ } catch (error) {
459
+ if (!isEnoent(error)) throw error;
460
+ const parent = path.dirname(current);
461
+ if (parent === current) throw error;
462
+ current = parent;
463
+ }
464
+ }
465
+ }
466
+
467
+ async function countVaultEntries(rootPath: string): Promise<VaultCounts> {
468
+ const pending = [rootPath];
469
+ let files = 0;
470
+ let folders = 0;
471
+ while (pending.length > 0) {
472
+ const dir = pending.pop();
473
+ if (!dir) continue;
474
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true });
475
+ for (const entry of entries) {
476
+ const entryPath = path.join(dir, entry.name);
477
+ if (entry.isDirectory()) {
478
+ folders++;
479
+ pending.push(entryPath);
480
+ } else if (entry.isFile()) {
481
+ files++;
482
+ }
483
+ }
484
+ }
485
+ return { files, folders };
486
+ }
487
+
488
+ function formatVaultPathForLink(ref: VaultReference, relativePath: string, trailingSlash: boolean): string {
489
+ const encodedVault = ref.active ? "_" : encodePathComponent(ref.display);
490
+ const encodedPath = encodeRelativePath(relativePath);
491
+ const suffix = trailingSlash ? "/" : "";
492
+ return encodedPath ? `vault://${encodedVault}/${encodedPath}${suffix}` : `vault://${encodedVault}/`;
493
+ }
494
+
495
+ function paramString(params: VaultParams, name: string): string | undefined {
496
+ const value = params[name];
497
+ return typeof value === "string" && value.length > 0 ? value : undefined;
498
+ }
499
+
500
+ function requireParam(params: VaultParams, name: string, op: string): string {
501
+ const value = paramString(params, name);
502
+ if (value) return value;
503
+ throw new Error(`vault://${op} requires '${name}' query parameter`);
504
+ }
505
+
506
+ function validateQueryPath(params: VaultParams, name: string): string | undefined {
507
+ const value = paramString(params, name);
508
+ if (!value) return undefined;
509
+ try {
510
+ validateRelativePath(value.replaceAll("\\", "/"));
511
+ } catch (error) {
512
+ throw toVaultValidationError(error);
513
+ }
514
+ return value;
515
+ }
516
+
517
+ export function buildObsidianCliInvocation(
518
+ parsed: Extract<ParsedVaultUrl, { kind: "file-op" | "vault-op" }>,
519
+ ): CliInvocation {
520
+ if (parsed.kind === "file-op") {
521
+ const pathArg = `path=${parsed.relativePath}`;
522
+ switch (parsed.op) {
523
+ case "outline":
524
+ return { args: ["outline", pathArg, "format=md"], contentType: "text/markdown", opLabel: "outline" };
525
+ case "backlinks":
526
+ return {
527
+ args: ["backlinks", pathArg, "counts", "format=tsv"],
528
+ contentType: "text/plain",
529
+ opLabel: "backlinks",
530
+ };
531
+ case "links":
532
+ return { args: ["links", pathArg], contentType: "text/plain", opLabel: "links" };
533
+ case "tags":
534
+ return {
535
+ args: ["tags", pathArg, "counts", "format=json"],
536
+ contentType: "application/json",
537
+ opLabel: "tags",
538
+ };
539
+ case "properties":
540
+ return {
541
+ args: ["properties", pathArg, "format=yaml"],
542
+ contentType: "text/markdown",
543
+ opLabel: "properties",
544
+ };
545
+ case "tasks":
546
+ return {
547
+ args: ["tasks", pathArg, "verbose", "format=json"],
548
+ contentType: "application/json",
549
+ opLabel: "tasks",
550
+ };
551
+ case "wordcount":
552
+ return { args: ["wordcount", pathArg], contentType: "text/plain", opLabel: "wordcount" };
553
+ case "history":
554
+ return { args: ["history", pathArg], contentType: "text/plain", opLabel: "history" };
555
+ case "base": {
556
+ const view = requireParam(parsed.params, "view", "base");
557
+ return {
558
+ args: ["base:query", pathArg, `view=${view}`, "format=md"],
559
+ contentType: "text/markdown",
560
+ opLabel: "base",
561
+ };
562
+ }
563
+ }
564
+ }
565
+
566
+ switch (parsed.op) {
567
+ case "search": {
568
+ const query = requireParam(parsed.params, "q", "search");
569
+ const args = ["search:context", `query=${query}`];
570
+ const pathFilter = validateQueryPath(parsed.params, "path");
571
+ if (pathFilter) args.push(`path=${pathFilter}`);
572
+ const limit = paramString(parsed.params, "limit");
573
+ if (limit) args.push(`limit=${limit}`);
574
+ if (parsed.params.case !== undefined) args.push("case");
575
+ args.push("format=json");
576
+ return { args, contentType: "application/json", opLabel: "search" };
577
+ }
578
+ case "daily":
579
+ return { args: ["daily:read"], contentType: "text/markdown", opLabel: "daily" };
580
+ case "daily-path":
581
+ return { args: ["daily:path"], contentType: "text/plain", opLabel: "daily-path" };
582
+ case "tags":
583
+ return {
584
+ args: ["tags", "counts", "format=json"],
585
+ contentType: "application/json",
586
+ opLabel: "tags",
587
+ };
588
+ case "tag": {
589
+ const tag = paramString(parsed.params, "name") ?? requireParam(parsed.params, "tag", "tag");
590
+ return { args: ["tag", `name=${tag}`, "verbose"], contentType: "text/plain", opLabel: "tag" };
591
+ }
592
+ case "tasks":
593
+ return {
594
+ args: ["tasks", "todo", "verbose", "format=json"],
595
+ contentType: "application/json",
596
+ opLabel: "tasks",
597
+ };
598
+ case "orphans":
599
+ return { args: ["orphans"], contentType: "text/plain", opLabel: "orphans" };
600
+ case "unresolved":
601
+ return {
602
+ args: ["unresolved", "counts", "verbose", "format=json"],
603
+ contentType: "application/json",
604
+ opLabel: "unresolved",
605
+ };
606
+ case "deadends":
607
+ return { args: ["deadends"], contentType: "text/plain", opLabel: "deadends" };
608
+ case "bases":
609
+ return { args: ["bases"], contentType: "text/plain", opLabel: "bases" };
610
+ case "bookmarks":
611
+ return {
612
+ args: ["bookmarks", "verbose", "format=json"],
613
+ contentType: "application/json",
614
+ opLabel: "bookmarks",
615
+ };
616
+ case "recents":
617
+ return { args: ["recents"], contentType: "text/plain", opLabel: "recents" };
618
+ case "templates":
619
+ return { args: ["templates"], contentType: "text/plain", opLabel: "templates" };
620
+ case "aliases":
621
+ return {
622
+ args: ["aliases", "verbose", "format=json"],
623
+ contentType: "application/json",
624
+ opLabel: "aliases",
625
+ };
626
+ case "properties":
627
+ return {
628
+ args: ["properties", "counts", "format=yaml"],
629
+ contentType: "text/markdown",
630
+ opLabel: "properties",
631
+ };
632
+ case "property": {
633
+ const name = requireParam(parsed.params, "name", "property");
634
+ const propertyPath = requireParam(parsed.params, "path", "property");
635
+ validateQueryPath(parsed.params, "path");
636
+ return {
637
+ args: ["property:read", `name=${name}`, `path=${propertyPath}`],
638
+ contentType: "text/plain",
639
+ opLabel: "property",
640
+ };
641
+ }
642
+ }
643
+ }
644
+
645
+ export class VaultProtocolHandler implements ProtocolHandler {
646
+ readonly scheme = "vault";
647
+ readonly immutable = false;
648
+
649
+ readonly #spawnObsidian: typeof spawnObsidian;
650
+ readonly #resolveObsidianBinary: () => string | null;
651
+
652
+ constructor(options: VaultProtocolHandlerOptions = {}) {
653
+ this.#spawnObsidian = options.spawnObsidian ?? spawnObsidian;
654
+ this.#resolveObsidianBinary = options.resolveObsidianBinary ?? resolveObsidianBinary;
655
+ }
656
+
657
+ static resetForTests(): void {
658
+ cachedObsidianBinary = undefined;
659
+ binaryOverrideForTests = undefined;
660
+ cachedVaultDirectory = undefined;
661
+ cachedActiveVaultPath = undefined;
662
+ cachedVaultInfo.clear();
663
+ }
664
+
665
+ static setObsidianBinaryForTests(value: string | null | undefined): void {
666
+ binaryOverrideForTests = value;
667
+ cachedObsidianBinary = undefined;
668
+ }
669
+
670
+ static setVaultDirectoryForTests(entries: ReadonlyMap<string, string> | Record<string, string> | undefined): void {
671
+ if (!entries) {
672
+ cachedVaultDirectory = undefined;
673
+ return;
674
+ }
675
+ if (entries instanceof Map) {
676
+ cachedVaultDirectory = new Map(entries);
677
+ return;
678
+ }
679
+ const record = entries as Record<string, string>;
680
+ cachedVaultDirectory = new Map<string, string>();
681
+ for (const name in record) {
682
+ cachedVaultDirectory.set(name, record[name]);
683
+ }
684
+ }
685
+
686
+ static setActiveVaultPathForTests(vaultPath: string | undefined): void {
687
+ cachedActiveVaultPath = vaultPath;
688
+ }
689
+
690
+ async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
691
+ if (!isVaultEnabled()) throw new VaultDisabledError();
692
+ const parsed = parseVaultUrl(url);
693
+ switch (parsed.kind) {
694
+ case "list-vaults":
695
+ return this.#listVaults(parsed, context);
696
+ case "vault-info":
697
+ return this.#vaultInfo(parsed, context);
698
+ case "fs-dir":
699
+ return this.#listDir(parsed, context);
700
+ case "fs-file":
701
+ return this.#readFile(parsed, context);
702
+ case "file-op":
703
+ case "vault-op":
704
+ return this.#runCli(parsed, context);
705
+ }
706
+ }
707
+
708
+ async write(url: InternalUrl, content: string, context?: WriteContext): Promise<void> {
709
+ if (!isVaultEnabled()) throw new VaultDisabledError();
710
+ const parsed = parseVaultUrl(url);
711
+ if (parsed.kind !== "fs-file") {
712
+ throw new Error("vault:// write only supports plain file paths");
713
+ }
714
+ await this.#writeFile(parsed, content, context);
715
+ }
716
+
717
+ async #spawn(args: string[], context?: ResolveContext | WriteContext): Promise<ObsidianSpawnResult> {
718
+ const bin = requireObsidianBinary(this.#resolveObsidianBinary);
719
+ return this.#spawnObsidian(bin, args, context?.signal, DEFAULT_OBSIDIAN_TIMEOUT_MS);
720
+ }
721
+
722
+ async #loadVaultDirectory(context?: ResolveContext | WriteContext): Promise<Map<string, string>> {
723
+ if (cachedVaultDirectory) return cachedVaultDirectory;
724
+ const result = await this.#spawn(["vaults", "verbose"], context);
725
+ assertCliSuccess("vaults", result);
726
+ cachedVaultDirectory = parseVaultDirectory(result.stdout);
727
+ return cachedVaultDirectory;
728
+ }
729
+
730
+ async #resolveVaultRoot(ref: VaultReference, context?: ResolveContext | WriteContext): Promise<string> {
731
+ const cached = getCachedVaultRoot(ref);
732
+ if (cached) return cached;
733
+
734
+ if (ref.active) {
735
+ const result = await this.#spawn(["vault", "info", "path"], context);
736
+ assertCliSuccess("vault info path", result);
737
+ const activePath = parseActiveVaultPath(result.stdout);
738
+ if (!activePath) {
739
+ throw new Error("vault:// active vault path was empty");
740
+ }
741
+ cachedActiveVaultPath = path.resolve(activePath);
742
+ return cachedActiveVaultPath;
743
+ }
744
+
745
+ if (!ref.vault) {
746
+ throw new Error("vault:// URL requires a vault name or '_' for the active vault");
747
+ }
748
+ const vaults = await this.#loadVaultDirectory(context);
749
+ const root = vaults.get(ref.vault);
750
+ if (!root) {
751
+ const available = Array.from(vaults.keys()).sort().join(", ") || "none";
752
+ throw new Error(`Unknown Obsidian vault: ${ref.vault}\nAvailable: ${available}`);
753
+ }
754
+ return path.resolve(root);
755
+ }
756
+
757
+ #vaultCliArg(ref: VaultReference): string[] {
758
+ return ref.forwardVault && ref.vault ? [`vault=${ref.vault}`] : [];
759
+ }
760
+
761
+ async #listVaults(
762
+ parsed: Extract<ParsedVaultUrl, { kind: "list-vaults" }>,
763
+ context?: ResolveContext,
764
+ ): Promise<InternalResource> {
765
+ const vaults = await this.#loadVaultDirectory(context);
766
+ const entries = Array.from(vaults.keys()).sort((a, b) => a.localeCompare(b));
767
+ const listing =
768
+ entries.length === 0
769
+ ? "(none)"
770
+ : entries.map(name => `- [${name}](vault://${encodePathComponent(name)}/)`).join("\n");
771
+ const content = `# Obsidian Vaults\n\n${entries.length} vault${entries.length === 1 ? "" : "s"} available:\n\n${listing}\n`;
772
+ return {
773
+ url: parsed.url,
774
+ content,
775
+ contentType: "text/markdown",
776
+ size: Buffer.byteLength(content, "utf-8"),
777
+ immutable: true,
778
+ };
779
+ }
780
+
781
+ async #vaultInfo(
782
+ parsed: Extract<ParsedVaultUrl, { kind: "vault-info" }>,
783
+ context?: ResolveContext,
784
+ ): Promise<InternalResource> {
785
+ const root = await this.#resolveVaultRoot(parsed.ref, context);
786
+ const cacheKey = parsed.ref.active ? "_" : (parsed.ref.vault ?? "_");
787
+ let cliInfo = cachedVaultInfo.get(cacheKey);
788
+ if (cliInfo === undefined) {
789
+ const result = await this.#spawn(["vault", "info", ...this.#vaultCliArg(parsed.ref)], context);
790
+ assertCliSuccess("vault info", result);
791
+ cliInfo = result.stdout.trim();
792
+ cachedVaultInfo.set(cacheKey, cliInfo);
793
+ }
794
+ const counts = await countVaultEntries(root);
795
+ const payload = {
796
+ name: parsed.ref.display,
797
+ rootPath: root,
798
+ files: counts.files,
799
+ folders: counts.folders,
800
+ info: cliInfo,
801
+ };
802
+ const content = `${JSON.stringify(payload, null, 2)}\n`;
803
+ return {
804
+ url: parsed.url,
805
+ content,
806
+ contentType: "application/json",
807
+ size: Buffer.byteLength(content, "utf-8"),
808
+ sourcePath: root,
809
+ immutable: true,
810
+ };
811
+ }
812
+
813
+ async #resolveFsTarget(
814
+ parsed: Extract<ParsedVaultUrl, { kind: "fs-dir" | "fs-file" }>,
815
+ context?: ResolveContext | WriteContext,
816
+ ): Promise<{ root: string; targetPath: string }> {
817
+ const root = await this.#resolveVaultRoot(parsed.ref, context);
818
+ const resolvedRoot = await fs.promises.realpath(root);
819
+ const targetPath = parsed.relativePath ? path.resolve(resolvedRoot, parsed.relativePath) : resolvedRoot;
820
+ ensureWithinRoot(targetPath, resolvedRoot);
821
+ return { root: resolvedRoot, targetPath };
822
+ }
823
+
824
+ async #listDir(
825
+ parsed: Extract<ParsedVaultUrl, { kind: "fs-dir" }>,
826
+ context?: ResolveContext,
827
+ ): Promise<InternalResource> {
828
+ const { root, targetPath } = await this.#resolveFsTarget(parsed, context);
829
+ const realTargetPath = await fs.promises.realpath(targetPath);
830
+ ensureWithinRoot(realTargetPath, root);
831
+ const stat = await fs.promises.stat(realTargetPath);
832
+ if (!stat.isDirectory()) {
833
+ throw new Error(`vault:// URL must resolve to a directory: ${parsed.url}`);
834
+ }
835
+ const entries = await fs.promises.readdir(realTargetPath, { withFileTypes: true });
836
+ entries.sort((a, b) => a.name.localeCompare(b.name));
837
+ const baseRelative = parsed.relativePath ? `${parsed.relativePath}/` : "";
838
+ const lines = entries.map(entry => {
839
+ const entryRelativePath = `${baseRelative}${entry.name}`;
840
+ const isDir = entry.isDirectory();
841
+ const href = formatVaultPathForLink(parsed.ref, entryRelativePath, isDir);
842
+ return `- [${entry.name}${isDir ? "/" : ""}](${href})`;
843
+ });
844
+ const listing = lines.length === 0 ? "(empty)" : lines.join("\n");
845
+ const titlePath = parsed.relativePath ? `/${parsed.relativePath}/` : "/";
846
+ const content = `# Vault ${parsed.ref.display}${titlePath}\n\n${entries.length} entr${entries.length === 1 ? "y" : "ies"}:\n\n${listing}\n`;
847
+ return {
848
+ url: parsed.url,
849
+ content,
850
+ contentType: "text/markdown",
851
+ size: Buffer.byteLength(content, "utf-8"),
852
+ sourcePath: realTargetPath,
853
+ immutable: true,
854
+ };
855
+ }
856
+
857
+ async #readFile(
858
+ parsed: Extract<ParsedVaultUrl, { kind: "fs-file" }>,
859
+ context?: ResolveContext,
860
+ ): Promise<InternalResource> {
861
+ const { root, targetPath } = await this.#resolveFsTarget(parsed, context);
862
+ const parentDir = path.dirname(targetPath);
863
+ try {
864
+ const realParent = await fs.promises.realpath(parentDir);
865
+ ensureWithinRoot(realParent, root);
866
+ } catch (error) {
867
+ if (!isEnoent(error)) throw error;
868
+ }
869
+
870
+ let realTargetPath: string;
871
+ try {
872
+ realTargetPath = await fs.promises.realpath(targetPath);
873
+ } catch (error) {
874
+ if (isEnoent(error)) {
875
+ throw new Error(`Vault file not found: ${parsed.url}`);
876
+ }
877
+ throw error;
878
+ }
879
+ ensureWithinRoot(realTargetPath, root);
880
+ const stat = await fs.promises.stat(realTargetPath);
881
+ if (!stat.isFile()) {
882
+ throw new Error(`vault:// URL must resolve to a file: ${parsed.url}`);
883
+ }
884
+
885
+ const content = await Bun.file(realTargetPath).text();
886
+ return {
887
+ url: parsed.url,
888
+ content,
889
+ contentType: getContentType(realTargetPath),
890
+ size: Buffer.byteLength(content, "utf-8"),
891
+ sourcePath: realTargetPath,
892
+ };
893
+ }
894
+
895
+ async #writeFile(
896
+ parsed: Extract<ParsedVaultUrl, { kind: "fs-file" }>,
897
+ content: string,
898
+ context?: WriteContext,
899
+ ): Promise<void> {
900
+ const { root, targetPath } = await this.#resolveFsTarget(parsed, context);
901
+ try {
902
+ const realTargetPath = await fs.promises.realpath(targetPath);
903
+ ensureWithinRoot(realTargetPath, root);
904
+ const stat = await fs.promises.stat(realTargetPath);
905
+ if (stat.isDirectory()) {
906
+ throw new Error(`vault:// URL must resolve to a file: ${parsed.url}`);
907
+ }
908
+ } catch (error) {
909
+ if (!isEnoent(error)) throw error;
910
+ const parentDir = path.dirname(targetPath);
911
+ const existingAncestor = await findExistingAncestor(parentDir, root);
912
+ ensureWithinRoot(existingAncestor, root);
913
+ await fs.promises.mkdir(parentDir, { recursive: true });
914
+ const realParent = await fs.promises.realpath(parentDir);
915
+ ensureWithinRoot(realParent, root);
916
+ }
917
+ await Bun.write(targetPath, content);
918
+ }
919
+
920
+ async #runCli(
921
+ parsed: Extract<ParsedVaultUrl, { kind: "file-op" | "vault-op" }>,
922
+ context?: ResolveContext,
923
+ ): Promise<InternalResource> {
924
+ const invocation = buildObsidianCliInvocation(parsed);
925
+ const args = [...invocation.args, ...this.#vaultCliArg(parsed.ref)];
926
+ const result = await this.#spawn(args, context);
927
+ assertCliSuccess(invocation.opLabel, result);
928
+ return {
929
+ url: parsed.url,
930
+ content: result.stdout,
931
+ contentType: invocation.contentType,
932
+ size: Buffer.byteLength(result.stdout, "utf-8"),
933
+ immutable: true,
934
+ };
935
+ }
936
+ }