@ikuma.cloud/pix-mcp 0.0.8 → 0.0.10

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.
package/src/index.ts CHANGED
@@ -1,20 +1,46 @@
1
1
  import { resolve } from "node:path";
2
2
  import { Type } from "@earendil-works/pi-ai";
3
- import type {
4
- ExtensionAPI,
5
- ExtensionCommandContext,
6
- ExtensionContext,
3
+ import {
4
+ getAgentDir,
5
+ type ExtensionAPI,
6
+ type ExtensionCommandContext,
7
+ type ExtensionContext,
7
8
  } from "@earendil-works/pi-coding-agent";
8
- import type { Prompt, Tool } from "@modelcontextprotocol/sdk/types.js";
9
+ import type {
10
+ Prompt,
11
+ Resource,
12
+ ResourceTemplate,
13
+ Tool,
14
+ } from "@modelcontextprotocol/sdk/types.js";
9
15
  import { compact, entry, search, summary, type Entry } from "./catalog.ts";
10
16
  import { Connection } from "./client.ts";
11
- import { readConfig, type ConfigIssue, type ServerConfig } from "./config.ts";
17
+ import {
18
+ mergeConfigs,
19
+ readConfig,
20
+ type ConfigIssue,
21
+ type ServerConfig,
22
+ } from "./config.ts";
12
23
  import {
13
24
  formatEditableContent,
14
25
  formatResult,
15
26
  removeEditablePromptGuard,
16
27
  } from "./output.ts";
17
28
  import { pickPrompt } from "./prompt-picker.ts";
29
+ import { pickResource } from "./resource-picker.ts";
30
+ import {
31
+ formatResourceList,
32
+ formatResourceResult,
33
+ parseResourceCommand,
34
+ resourceCompletions,
35
+ resourceEntryKey,
36
+ resourceKey,
37
+ resourceTemplateVariables,
38
+ resolveResourceUri,
39
+ type ResourceArgumentToken,
40
+ type ResourceCommand,
41
+ type ResourceEntry,
42
+ type TemplateResourceEntry,
43
+ } from "./resources.ts";
18
44
  import {
19
45
  formatPromptList,
20
46
  formatPromptResult,
@@ -44,17 +70,29 @@ interface PromptSelection {
44
70
  item: PromptEntry;
45
71
  }
46
72
 
73
+ interface ResourceSelection {
74
+ command: ResourceCommand;
75
+ item: ResourceEntry;
76
+ }
77
+
78
+ type ConfigScope = "explicit" | "global" | "project";
79
+ interface ConfigSource {
80
+ scope: ConfigScope;
81
+ path: string;
82
+ }
83
+
47
84
  interface State {
48
85
  alive: boolean;
49
86
  status: string;
50
87
  entries: Map<string, Entry>;
51
88
  prompts: Map<string, PromptEntry>;
89
+ resources: Map<string, ResourceEntry>;
52
90
  loaded: Map<string, string>;
53
91
  connections: Map<string, Connection>;
54
92
  rejected: Map<string, Rejections>;
55
93
  configIssues: ConfigIssue[];
56
- promptDraftCleanups: Set<() => Promise<void>>;
57
- promptDraftGuards: Set<string>;
94
+ draftCleanups: Set<() => Promise<void>>;
95
+ draftGuards: Set<string>;
58
96
  }
59
97
 
60
98
  const discoveryParameters = Type.Object(
@@ -85,7 +123,7 @@ export default function mcp(pi: ExtensionAPI) {
85
123
  pi.registerFlag("mcp-config", {
86
124
  type: "string",
87
125
  description:
88
- "Read this MCP config file for this session (default: .mcp.json).",
126
+ "Read only this MCP config file for this session (otherwise merge the global and project configs).",
89
127
  });
90
128
  pi.registerCommand("mcp-prompt", {
91
129
  description: "Select, list, or run a user-controlled MCP prompt",
@@ -178,8 +216,8 @@ export default function mcp(pi: ExtensionAPI) {
178
216
  ensureSelectedPromptCurrent();
179
217
  ctx.signal?.throwIfAborted();
180
218
  ctx.ui.pasteToEditor(draft.text);
181
- owner.promptDraftCleanups.add(draft.cleanup);
182
- if (draft.guard) owner.promptDraftGuards.add(draft.guard);
219
+ owner.draftCleanups.add(draft.cleanup);
220
+ if (draft.guard) owner.draftGuards.add(draft.guard);
183
221
  } catch (error) {
184
222
  await draft.cleanup();
185
223
  throw error;
@@ -202,6 +240,197 @@ export default function mcp(pi: ExtensionAPI) {
202
240
  },
203
241
  });
204
242
 
243
+ pi.registerCommand("mcp-resource", {
244
+ description: "Select, list, or read a user-controlled MCP resource",
245
+ getArgumentCompletions: (prefix) =>
246
+ resourceCompletions(
247
+ prefix,
248
+ [...(state?.resources.values() ?? [])],
249
+ state?.connections.keys() ?? [],
250
+ ),
251
+ async handler(input, ctx) {
252
+ try {
253
+ const owner = state;
254
+ if (!owner) throw new Error("MCP session has not started.");
255
+ current(owner);
256
+ const editBeforeSending = ctx.mode === "tui" && input.trim() === "";
257
+ const selection = editBeforeSending
258
+ ? await chooseResource(owner, ctx)
259
+ : undefined;
260
+ const command =
261
+ selection?.command ??
262
+ (editBeforeSending ? undefined : parseResourceCommand(input));
263
+ if (!command) return;
264
+ if (command.action === "list") {
265
+ if (
266
+ command.server &&
267
+ !owner.connections.has(command.server) &&
268
+ !owner.configIssues.some((issue) => issue.name === command.server)
269
+ )
270
+ throw new Error("Unknown MCP server.");
271
+ const resourceStatus = [
272
+ ...owner.configIssues
273
+ .filter(
274
+ (issue) => !command.server || issue.name === command.server,
275
+ )
276
+ .map((issue) => `${issue.name}: ${issue.message}`),
277
+ ...[...owner.connections.values()]
278
+ .filter(
279
+ (connection) =>
280
+ !command.server || connection.config.name === command.server,
281
+ )
282
+ .map(
283
+ (connection) =>
284
+ `${connection.config.name}: ${connection.resourceStatus}`,
285
+ ),
286
+ ].join("\n");
287
+ const text = `${resourceStatus}${resourceStatus ? "\n\n" : ""}${formatResourceList(
288
+ [...owner.resources.values()],
289
+ command.server,
290
+ )}`;
291
+ if (!ctx.hasUI) throw new Error(text);
292
+ ctx.ui.notify(text, "info");
293
+ return;
294
+ }
295
+
296
+ const direct = owner.resources.get(
297
+ resourceKey(command.server, "resource", command.target),
298
+ );
299
+ const template = owner.resources.get(
300
+ resourceKey(command.server, "template", command.target),
301
+ );
302
+ const item =
303
+ selection?.item ??
304
+ (command.argumentTokens.length > 0 ? template : (direct ?? template));
305
+ const selectedKey = item ? resourceEntryKey(item) : undefined;
306
+ const ensureSelectedResourceCurrent = () => {
307
+ if (
308
+ selection &&
309
+ selectedKey &&
310
+ owner.resources.get(selectedKey) !== item
311
+ )
312
+ throw new Error(
313
+ "MCP resource catalog changed. Open the picker again.",
314
+ );
315
+ };
316
+ ensureSelectedResourceCurrent();
317
+ const templateEntry =
318
+ item?.kind === "template"
319
+ ? item
320
+ : template?.kind === "template"
321
+ ? template
322
+ : undefined;
323
+ const uri = resolveResourceUri(
324
+ command.target,
325
+ templateEntry,
326
+ command.argumentTokens,
327
+ );
328
+ const connection = owner.connections.get(command.server);
329
+ if (!connection) throw new Error("MCP connection unavailable.");
330
+ if (ctx.mode === "tui")
331
+ ctx.ui.setStatus(
332
+ "mcp-resource",
333
+ `Loading ${command.server} / ${compact(uri, 120)}…`,
334
+ );
335
+ try {
336
+ const result = await connection.readResource(uri, ctx.signal);
337
+ current(owner);
338
+ ensureSelectedResourceCurrent();
339
+ const formatted = await formatResourceResult(command.server, result);
340
+ current(owner);
341
+ ensureSelectedResourceCurrent();
342
+ ctx.signal?.throwIfAborted();
343
+ if (editBeforeSending) {
344
+ const draft = await formatEditableContent(formatted.content);
345
+ try {
346
+ current(owner);
347
+ ensureSelectedResourceCurrent();
348
+ ctx.signal?.throwIfAborted();
349
+ ctx.ui.pasteToEditor(draft.text);
350
+ owner.draftCleanups.add(draft.cleanup);
351
+ if (draft.guard) owner.draftGuards.add(draft.guard);
352
+ } catch (error) {
353
+ await draft.cleanup();
354
+ throw error;
355
+ }
356
+ } else {
357
+ pi.sendUserMessage(
358
+ formatted.content,
359
+ ctx.isIdle() ? undefined : { deliverAs: "followUp" },
360
+ );
361
+ }
362
+ } finally {
363
+ if (ctx.mode === "tui") ctx.ui.setStatus("mcp-resource", undefined);
364
+ }
365
+ } catch (error) {
366
+ const message =
367
+ error instanceof Error ? error.message : "MCP resource failed.";
368
+ if (!ctx.hasUI) throw new Error(message);
369
+ ctx.ui.notify(message, "error");
370
+ }
371
+ },
372
+ });
373
+
374
+ async function chooseResource(
375
+ owner: State,
376
+ ctx: ExtensionCommandContext,
377
+ ): Promise<ResourceSelection | undefined> {
378
+ const entries = [...owner.resources.values()].sort(
379
+ (a, b) =>
380
+ a.server.localeCompare(b.server, "en") ||
381
+ resourceEntryKey(a).localeCompare(resourceEntryKey(b), "en"),
382
+ );
383
+ if (entries.length === 0) {
384
+ ctx.ui.notify("No MCP resources are available.", "warning");
385
+ return;
386
+ }
387
+
388
+ const item = await pickResource(entries, ctx);
389
+ if (!item) return;
390
+ ctx.signal?.throwIfAborted();
391
+
392
+ const key = resourceEntryKey(item);
393
+ const ensureCurrent = () => {
394
+ current(owner);
395
+ if (owner.resources.get(key) !== item)
396
+ throw new Error("MCP resource catalog changed. Open the picker again.");
397
+ };
398
+ ensureCurrent();
399
+
400
+ const dialogOptions = ctx.signal ? { signal: ctx.signal } : undefined;
401
+ const argumentTokens: ResourceArgumentToken[] = [];
402
+ if (item.kind === "template") {
403
+ for (const variable of item.variables) {
404
+ const value = await ctx.ui.input(
405
+ `${variable} (optional)`,
406
+ "Leave empty to omit",
407
+ dialogOptions,
408
+ );
409
+ if (value === undefined) return;
410
+ ctx.signal?.throwIfAborted();
411
+ ensureCurrent();
412
+ if (value !== "")
413
+ argumentTokens.push({
414
+ value: `${variable}=${value}`,
415
+ separator: variable.length,
416
+ });
417
+ }
418
+ }
419
+
420
+ return {
421
+ command: {
422
+ action: "read",
423
+ server: item.server,
424
+ target:
425
+ item.kind === "resource"
426
+ ? item.resource.uri
427
+ : item.template.uriTemplate,
428
+ argumentTokens,
429
+ },
430
+ item,
431
+ };
432
+ }
433
+
205
434
  async function choosePrompt(
206
435
  owner: State,
207
436
  ctx: ExtensionCommandContext,
@@ -441,6 +670,35 @@ export default function mcp(pi: ExtensionAPI) {
441
670
  }
442
671
  }
443
672
 
673
+ function synchronizeResources(
674
+ owner: State,
675
+ config: ServerConfig,
676
+ resources: Resource[],
677
+ templates: ResourceTemplate[],
678
+ ) {
679
+ if (!owner.alive || state !== owner) return;
680
+ for (const [key, item] of owner.resources) {
681
+ if (item.server === config.name) owner.resources.delete(key);
682
+ }
683
+ for (const resource of resources) {
684
+ const item: ResourceEntry = {
685
+ kind: "resource",
686
+ server: config.name,
687
+ resource,
688
+ };
689
+ owner.resources.set(resourceEntryKey(item), item);
690
+ }
691
+ for (const template of templates) {
692
+ const item: TemplateResourceEntry = {
693
+ kind: "template",
694
+ server: config.name,
695
+ template,
696
+ variables: resourceTemplateVariables(template.uriTemplate),
697
+ };
698
+ owner.resources.set(resourceEntryKey(item), item);
699
+ }
700
+ }
701
+
444
702
  function synchronize(owner: State, config: ServerConfig, tools: Tool[]) {
445
703
  if (!owner.alive || state !== owner) return;
446
704
  const previous = new Map(
@@ -551,45 +809,80 @@ export default function mcp(pi: ExtensionAPI) {
551
809
  owner.alive = false;
552
810
  state = undefined;
553
811
  deactivate(owned);
554
- const promptDraftCleanups = [...owner.promptDraftCleanups];
555
- owner.promptDraftCleanups.clear();
556
- owner.promptDraftGuards.clear();
812
+ const draftCleanups = [...owner.draftCleanups];
813
+ owner.draftCleanups.clear();
814
+ owner.draftGuards.clear();
557
815
  await Promise.all([
558
816
  ...[...owner.connections.values()].map((connection) =>
559
817
  connection.close(),
560
818
  ),
561
- ...promptDraftCleanups.map((cleanup) => cleanup()),
819
+ ...draftCleanups.map((cleanup) => cleanup()),
562
820
  ]);
563
821
  }
564
822
 
565
823
  async function start(ctx: ExtensionContext) {
566
824
  await stop();
825
+ const showConfigNotices =
826
+ ctx.hasUI && (ctx.mode === "tui" || ctx.mode === "rpc");
567
827
  const owner: State = {
568
828
  alive: true,
569
829
  status: "No MCP servers configured.",
570
830
  entries: new Map(),
571
831
  prompts: new Map(),
832
+ resources: new Map(),
572
833
  loaded: new Map(),
573
834
  connections: new Map(),
574
835
  rejected: new Map(),
575
836
  configIssues: [],
576
- promptDraftCleanups: new Set(),
577
- promptDraftGuards: new Set(),
837
+ draftCleanups: new Set(),
838
+ draftGuards: new Set(),
578
839
  };
579
840
  state = owner;
841
+ let failureNotice: string | undefined;
580
842
  try {
581
843
  const flag = pi.getFlag("mcp-config");
582
- const explicit = typeof flag === "string" && flag.length > 0;
583
- const path = resolve(ctx.cwd, explicit ? flag : ".mcp.json");
584
- const config = await readConfig(path);
844
+ if (flag === "") {
845
+ failureNotice = "MCP config (explicit) failed: no path provided";
846
+ throw new Error(
847
+ "The explicitly selected MCP configuration path is empty.",
848
+ );
849
+ }
850
+ const explicit = typeof flag === "string";
851
+ const sources: ConfigSource[] = explicit
852
+ ? [{ scope: "explicit", path: resolve(ctx.cwd, flag) }]
853
+ : [
854
+ {
855
+ scope: "global",
856
+ path: resolve(getAgentDir(), "mcp.json"),
857
+ },
858
+ { scope: "project", path: resolve(ctx.cwd, ".mcp.json") },
859
+ ];
860
+ const loaded = [];
861
+ for (const source of sources) {
862
+ failureNotice = `MCP config (${source.scope}) failed: ${source.path}`;
863
+ const config = await readConfig(source.path);
864
+ current(owner);
865
+ failureNotice = undefined;
866
+ if (config) {
867
+ loaded.push({ ...source, config });
868
+ if (showConfigNotices)
869
+ ctx.ui.notify(
870
+ `MCP config (${source.scope}): ${source.path}`,
871
+ "info",
872
+ );
873
+ }
874
+ }
585
875
  current(owner);
586
- if (!config) {
876
+ if (loaded.length === 0) {
587
877
  if (explicit)
588
878
  owner.status =
589
879
  "The explicitly selected MCP configuration does not exist.";
880
+ if (showConfigNotices) ctx.ui.notify("MCP config: none found", "info");
590
881
  return;
591
882
  }
592
- if (ctx.hasUI) ctx.ui.notify(`MCP config: ${path}`, "info");
883
+ failureNotice = "MCP configuration merge failed.";
884
+ const config = mergeConfigs(loaded.map((source) => source.config));
885
+ failureNotice = undefined;
593
886
  owner.configIssues = config.issues;
594
887
  owner.status =
595
888
  config.issues.length > 0
@@ -606,6 +899,8 @@ export default function mcp(pi: ExtensionAPI) {
606
899
  server,
607
900
  (tools) => synchronize(owner, server, tools),
608
901
  (prompts) => synchronizePrompts(owner, server, prompts),
902
+ (resources, templates) =>
903
+ synchronizeResources(owner, server, resources, templates),
609
904
  ),
610
905
  );
611
906
  }
@@ -621,9 +916,12 @@ export default function mcp(pi: ExtensionAPI) {
621
916
  }),
622
917
  );
623
918
  } catch (error) {
624
- if (owner.alive)
919
+ if (owner.alive) {
625
920
  owner.status =
626
921
  error instanceof Error ? error.message : "MCP initialization failed.";
922
+ if (showConfigNotices)
923
+ ctx.ui.notify(failureNotice ?? "MCP initialization failed.", "error");
924
+ }
627
925
  } finally {
628
926
  if (owner.alive && state === owner) registerDiscovery();
629
927
  }
@@ -637,7 +935,7 @@ export default function mcp(pi: ExtensionAPI) {
637
935
  const consumed = new Set<string>();
638
936
  const removeKnownGuards = (text: string) => {
639
937
  let next = text;
640
- for (const guard of owner.promptDraftGuards) {
938
+ for (const guard of owner.draftGuards) {
641
939
  if (!next.includes(guard)) continue;
642
940
  next = removeEditablePromptGuard(next, guard);
643
941
  consumed.add(guard);
@@ -654,7 +952,7 @@ export default function mcp(pi: ExtensionAPI) {
654
952
  : block,
655
953
  );
656
954
  }
657
- for (const guard of consumed) owner.promptDraftGuards.delete(guard);
955
+ for (const guard of consumed) owner.draftGuards.delete(guard);
658
956
  if (consumed.size > 0) return { message: { ...event.message, content } };
659
957
  });
660
958
 
package/src/output.ts CHANGED
@@ -29,6 +29,7 @@ interface ContentOptions {
29
29
  structuredContent?: unknown;
30
30
  preserveErrorImages?: boolean;
31
31
  preserveOrder?: boolean;
32
+ artifactRequired?: boolean;
32
33
  }
33
34
 
34
35
  export async function formatContent(
@@ -83,6 +84,7 @@ export async function formatContent(
83
84
  textTruncated ||
84
85
  omitted ||
85
86
  largeDetails ||
87
+ options.artifactRequired === true ||
86
88
  (options.preserveErrorImages === true && images.length > 0);
87
89
  let fullOutputPath: string | undefined;
88
90
  if (truncated) {
@@ -141,7 +143,7 @@ export async function formatContent(
141
143
  };
142
144
  }
143
145
 
144
- function createEditablePromptGuard(): string {
146
+ function createEditableContentGuard(): string {
145
147
  const bytes = randomBytes(8);
146
148
  let guard = "\u2060\u2063";
147
149
  for (const byte of bytes)
@@ -154,12 +156,12 @@ export function removeEditablePromptGuard(text: string, guard: string): string {
154
156
  return text.replaceAll(guard, "");
155
157
  }
156
158
 
157
- function prepareEditablePrompt(text: string): {
159
+ function prepareEditableContent(text: string): {
158
160
  text: string;
159
161
  guard?: string;
160
162
  } {
161
163
  // The TUI interprets leading / and ! as commands. An invisible guard keeps
162
- // untrusted prompt text inert in the editor and is removed at message_end.
164
+ // untrusted staged text inert in the editor and is removed at message_end.
163
165
  const sanitized = text
164
166
  .replace(/\r\n?/gu, "\n")
165
167
  .replace(/\p{Cc}/gu, (character) =>
@@ -167,7 +169,7 @@ function prepareEditablePrompt(text: string): {
167
169
  )
168
170
  .replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/gu, "");
169
171
  if (!/^[!/]/u.test(sanitized.trimStart())) return { text: sanitized };
170
- const guard = createEditablePromptGuard();
172
+ const guard = createEditableContentGuard();
171
173
  return { text: `${guard}${sanitized}`, guard };
172
174
  }
173
175
 
@@ -190,12 +192,9 @@ export async function formatEditableContent(
190
192
  text += block.text;
191
193
  continue;
192
194
  }
193
- imageDirectory ??= await mkdtemp(join(tmpdir(), "pix-mcp-prompt-"));
195
+ imageDirectory ??= await mkdtemp(join(tmpdir(), "pix-mcp-draft-"));
194
196
  const extension = imageExtensions[block.mimeType] ?? "img";
195
- const path = join(
196
- imageDirectory,
197
- `prompt-image-${++imageIndex}.${extension}`,
198
- );
197
+ const path = join(imageDirectory, `image-${++imageIndex}.${extension}`);
199
198
  await writeFile(path, Buffer.from(block.data, "base64"), { mode: 0o600 });
200
199
  const separator = text.endsWith("\n\n")
201
200
  ? ""
@@ -210,7 +209,7 @@ export async function formatEditableContent(
210
209
  throw error;
211
210
  }
212
211
  return {
213
- ...prepareEditablePrompt(text),
212
+ ...prepareEditableContent(text),
214
213
  cleanup: async () => {
215
214
  if (imageDirectory)
216
215
  await rm(imageDirectory, { recursive: true, force: true });
package/src/prompts.ts CHANGED
@@ -4,6 +4,12 @@ import type {
4
4
  Prompt,
5
5
  } from "@modelcontextprotocol/sdk/types.js";
6
6
  import { compact } from "./catalog.ts";
7
+ import {
8
+ quoteCommandArgument,
9
+ renderCommandToken,
10
+ tokenizeCommand,
11
+ type CommandToken,
12
+ } from "./command.ts";
7
13
  import { formatContent } from "./output.ts";
8
14
 
9
15
  export interface PromptEntry {
@@ -11,10 +17,7 @@ export interface PromptEntry {
11
17
  prompt: Prompt;
12
18
  }
13
19
 
14
- export interface PromptArgumentToken {
15
- value: string;
16
- separator?: number;
17
- }
20
+ export type PromptArgumentToken = CommandToken;
18
21
 
19
22
  export type PromptCommand =
20
23
  | { action: "list"; server?: string }
@@ -30,7 +33,7 @@ export function promptKey(server: string, name: string): string {
30
33
  }
31
34
 
32
35
  export function parsePromptCommand(input: string): PromptCommand {
33
- const tokens = tokenize(input);
36
+ const tokens = tokenizeCommand(input, "prompt");
34
37
  const action = tokens.shift()?.value;
35
38
  if (action === "list") {
36
39
  if (tokens.length > 1) throw new Error("Usage: /mcp-prompt list [server]");
@@ -120,15 +123,15 @@ export function formatPromptList(
120
123
  const argumentsHint = (entry.prompt.arguments ?? [])
121
124
  .map((argument) =>
122
125
  argument.required
123
- ? `<${quote(argument.name)}>`
124
- : `[${quote(argument.name)}]`,
126
+ ? `<${quoteCommandArgument(argument.name)}>`
127
+ : `[${quoteCommandArgument(argument.name)}]`,
125
128
  )
126
129
  .join(" ");
127
130
  const description = compact(
128
131
  entry.prompt.description ?? entry.prompt.title ?? "",
129
132
  160,
130
133
  );
131
- const line = `${entry.server} ${quote(entry.prompt.name)}${argumentsHint ? ` ${argumentsHint}` : ""}${description ? ` — ${description}` : ""}`;
134
+ const line = `${entry.server} ${quoteCommandArgument(entry.prompt.name)}${argumentsHint ? ` ${argumentsHint}` : ""}${description ? ` — ${description}` : ""}`;
132
135
  if (shown >= 100 || lines.join("\n").length + line.length > 15_000) {
133
136
  lines.push(`${selected.length - shown} additional prompts omitted.`);
134
137
  break;
@@ -149,7 +152,7 @@ export function promptCompletions(
149
152
  const trailingSpace = /\s$/.test(prefix);
150
153
  let tokens: PromptArgumentToken[];
151
154
  try {
152
- tokens = tokenize(prefix);
155
+ tokens = tokenizeCommand(prefix);
153
156
  } catch {
154
157
  return null;
155
158
  }
@@ -191,7 +194,7 @@ export function promptCompletions(
191
194
  )
192
195
  .sort((a, b) => a.prompt.name.localeCompare(b.prompt.name, "en"));
193
196
  return prompts.map((entry) => ({
194
- value: `run ${quote(server)} ${quote(entry.prompt.name)}`,
197
+ value: `run ${quoteCommandArgument(server)} ${quoteCommandArgument(entry.prompt.name)}`,
195
198
  label: entry.prompt.name,
196
199
  ...(entry.prompt.description || entry.prompt.title
197
200
  ? {
@@ -237,9 +240,9 @@ export function promptCompletions(
237
240
  }
238
241
  const base = [
239
242
  "run",
240
- quote(server),
241
- quote(selected.prompt.name),
242
- ...supplied.map((token) => quote(token.value)),
243
+ quoteCommandArgument(server),
244
+ quoteCommandArgument(selected.prompt.name),
245
+ ...supplied.map(renderCommandToken),
243
246
  ].join(" ");
244
247
  const arguments_ = definitions.filter(
245
248
  (argument) =>
@@ -247,7 +250,7 @@ export function promptCompletions(
247
250
  );
248
251
  return arguments_.length > 0
249
252
  ? arguments_.map((argument) => ({
250
- value: `${base} ${quote(argument.name)}=`,
253
+ value: `${base} ${quoteCommandArgument(argument.name)}=`,
251
254
  label: `${argument.name}=`,
252
255
  ...(argument.description
253
256
  ? { description: compact(argument.description, 100) }
@@ -262,66 +265,11 @@ function completionItems(
262
265
  label: (value: string) => string,
263
266
  ) {
264
267
  return values.map((value) => ({
265
- value: `${prefix}${quote(value)}`,
268
+ value: `${prefix}${quoteCommandArgument(value)}`,
266
269
  label: label(value),
267
270
  }));
268
271
  }
269
272
 
270
- function quote(value: string): string {
271
- return /^[A-Za-z0-9_.:/-]+$/.test(value)
272
- ? value
273
- : `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
274
- }
275
-
276
- function tokenize(input: string): PromptArgumentToken[] {
277
- const tokens: PromptArgumentToken[] = [];
278
- let token = "";
279
- let separator: number | undefined;
280
- let quote: '"' | "'" | undefined;
281
- let escaped = false;
282
- let started = false;
283
- for (const character of input) {
284
- if (escaped) {
285
- token += character;
286
- escaped = false;
287
- started = true;
288
- } else if (character === "\\" && quote !== "'") {
289
- escaped = true;
290
- started = true;
291
- } else if (quote) {
292
- if (character === quote) quote = undefined;
293
- else token += character;
294
- started = true;
295
- } else if (character === '"' || character === "'") {
296
- quote = character;
297
- started = true;
298
- } else if (/\s/u.test(character)) {
299
- if (started) {
300
- tokens.push({
301
- value: token,
302
- ...(separator !== undefined ? { separator } : {}),
303
- });
304
- token = "";
305
- separator = undefined;
306
- started = false;
307
- }
308
- } else {
309
- if (character === "=" && separator === undefined)
310
- separator = token.length;
311
- token += character;
312
- started = true;
313
- }
314
- }
315
- if (quote) throw new Error("Unterminated quote in prompt arguments.");
316
- if (escaped) throw new Error("Trailing escape in prompt arguments.");
317
- if (started)
318
- tokens.push({
319
- value: token,
320
- ...(separator !== undefined ? { separator } : {}),
321
- });
322
- return tokens;
323
- }
324
-
325
273
  function hasUsablePromptContent(
326
274
  content: GetPromptResult["messages"][number]["content"],
327
275
  ): boolean {