@vedmalex/ai-connect 0.5.0 → 0.6.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.
package/dist/bun/index.js CHANGED
@@ -1,3 +1,125 @@
1
+ // src/cli-presets.ts
2
+ var AI_CONNECT_DEFAULT_CLI_PRESETS = {
3
+ pi: {
4
+ id: "pi",
5
+ label: "pi (coding agent)",
6
+ command: "pi",
7
+ transportId: "pi-cli",
8
+ options: {
9
+ preset: "pi",
10
+ // pi print mode emits the answer as plain text on stdout (no JSON wrapper),
11
+ // and has no ACP mode — so it uses the raw `text` parser and no discovery sidecar.
12
+ // pi takes file/image attachments as separate `@<path>` argv tokens (REQ-024);
13
+ // {files} expands one `@<staged-path>` token per attachment. pi stages all
14
+ // categories (PDFs as-is, no extraction guarantee).
15
+ argsTemplate: ["--print", "--model", "{model}", "{files}", "{prompt}"],
16
+ discovery: {
17
+ via: "none"
18
+ },
19
+ parser: {
20
+ kind: "text"
21
+ },
22
+ fileInput: {
23
+ placement: "args",
24
+ perFileArgs: ["@{path}"]
25
+ }
26
+ }
27
+ },
28
+ claude: {
29
+ id: "claude",
30
+ label: "Claude CLI",
31
+ command: "claude",
32
+ transportId: "claude-cli",
33
+ options: {
34
+ preset: "claude",
35
+ argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "{prompt}"],
36
+ discovery: {
37
+ via: "acp",
38
+ acp: {
39
+ transportId: "claude-code-acp"
40
+ }
41
+ },
42
+ parser: {
43
+ kind: "json",
44
+ textPath: "__preset__:claude.result",
45
+ usagePath: "usage",
46
+ errorPath: "__preset__:claude.error"
47
+ }
48
+ }
49
+ },
50
+ openclaude: {
51
+ id: "openclaude",
52
+ label: "OpenClaude CLI",
53
+ command: "openclaude",
54
+ transportId: "openclaude-cli",
55
+ options: {
56
+ preset: "openclaude",
57
+ argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "{prompt}"],
58
+ parser: {
59
+ kind: "json",
60
+ textPath: "__preset__:claude.result",
61
+ usagePath: "usage",
62
+ errorPath: "__preset__:claude.error"
63
+ }
64
+ }
65
+ },
66
+ codex: {
67
+ id: "codex",
68
+ label: "Codex CLI",
69
+ command: "codex",
70
+ transportId: "codex-cli",
71
+ options: {
72
+ preset: "codex",
73
+ // codex exec takes image attachments via `-i/--image <FILE>` flags
74
+ // (REQ-024); {files} expands a `--image <staged-path>` pair per image.
75
+ // Images only — a non-image attachment cleanly rejects pre-spawn.
76
+ argsTemplate: [
77
+ "exec",
78
+ "--json",
79
+ "--output-last-message",
80
+ "{output_file}",
81
+ "--model",
82
+ "{model}",
83
+ "{files}",
84
+ "{prompt}"
85
+ ],
86
+ discovery: {
87
+ via: "acp",
88
+ acp: {
89
+ transportId: "codex-acp"
90
+ }
91
+ },
92
+ fileInput: {
93
+ placement: "args",
94
+ perFileArgs: ["--image", "{path}"],
95
+ categories: ["image"]
96
+ },
97
+ parser: {
98
+ kind: "jsonl",
99
+ text: {
100
+ path: "__preset__:codex.text"
101
+ },
102
+ usage: {
103
+ path: "__preset__:codex.usage"
104
+ },
105
+ error: {
106
+ path: "__preset__:codex.error"
107
+ }
108
+ }
109
+ }
110
+ }
111
+ };
112
+ var AI_CONNECT_DEFAULT_CLI_COMMANDS = {
113
+ "anthropic:claude-cli": AI_CONNECT_DEFAULT_CLI_PRESETS.claude.command,
114
+ "anthropic:openclaude-cli": AI_CONNECT_DEFAULT_CLI_PRESETS.openclaude.command,
115
+ "openclaude:openclaude-cli": AI_CONNECT_DEFAULT_CLI_PRESETS.openclaude.command,
116
+ "openai:codex-cli": AI_CONNECT_DEFAULT_CLI_PRESETS.codex.command,
117
+ "pi:pi-cli": AI_CONNECT_DEFAULT_CLI_PRESETS.pi.command
118
+ };
119
+ function getCliTransportPreset(presetId) {
120
+ return AI_CONNECT_DEFAULT_CLI_PRESETS[presetId];
121
+ }
122
+
1
123
  // src/errors.ts
2
124
  var AiConnectError = class extends Error {
3
125
  code;
@@ -345,9 +467,38 @@ function normalizeTransport(providerId, input) {
345
467
  ...fallback !== void 0 ? { fallback } : {}
346
468
  };
347
469
  })();
470
+ const fileInput = (() => {
471
+ const fi = descriptor.cli?.fileInput;
472
+ if (!fi) {
473
+ return void 0;
474
+ }
475
+ const validCategories = ["image", "document", "text", "other"];
476
+ if (fi.categories) {
477
+ for (const c of fi.categories) {
478
+ assert(
479
+ validCategories.includes(c),
480
+ `Unsupported CLI fileInput category "${String(c)}" for provider "${providerId}".`
481
+ );
482
+ }
483
+ }
484
+ const placement = fi.placement ?? "args";
485
+ assert(
486
+ placement === "args" || placement === "prompt",
487
+ `Unsupported CLI fileInput placement "${String(placement)}" for provider "${providerId}".`
488
+ );
489
+ return {
490
+ placement,
491
+ ...fi.perFileArgs ? { perFileArgs: fi.perFileArgs.map((part) => String(part)) } : {},
492
+ ...fi.mentionTemplate?.trim() ? { mentionTemplate: fi.mentionTemplate.trim() } : {},
493
+ ...fi.separator !== void 0 ? { separator: fi.separator } : {},
494
+ ...fi.categories ? { categories: [...fi.categories] } : {},
495
+ ...fi.stagingDir?.trim() ? { stagingDir: fi.stagingDir.trim() } : {}
496
+ };
497
+ })();
348
498
  return {
349
499
  ...normalized,
350
- ...discovery ? { discovery } : {}
500
+ ...discovery ? { discovery } : {},
501
+ ...fileInput ? { fileInput } : {}
351
502
  };
352
503
  })();
353
504
  const normalizedAuth = descriptor.auth?.methodId?.trim() ? {
@@ -435,7 +586,40 @@ function normalizeRuntime(transport, runtime) {
435
586
  }
436
587
  return "universal";
437
588
  }
589
+ function cliPresetIdForTransportId(transportId) {
590
+ switch (transportId) {
591
+ case "pi-cli":
592
+ return "pi";
593
+ case "claude-cli":
594
+ return "claude";
595
+ case "openclaude-cli":
596
+ return "openclaude";
597
+ case "codex-cli":
598
+ return "codex";
599
+ default:
600
+ return void 0;
601
+ }
602
+ }
603
+ function cliFileInputCategories(transport) {
604
+ if (transport.kind !== "cli") {
605
+ return /* @__PURE__ */ new Set();
606
+ }
607
+ const presetId = transport.cli?.preset ?? cliPresetIdForTransportId(transport.id);
608
+ const presetFileInput = presetId ? AI_CONNECT_DEFAULT_CLI_PRESETS[presetId]?.options.fileInput : void 0;
609
+ const routeFileInput = transport.cli?.fileInput;
610
+ if (!presetFileInput && !routeFileInput) {
611
+ return /* @__PURE__ */ new Set();
612
+ }
613
+ const merged = {
614
+ ...presetFileInput ?? {},
615
+ ...routeFileInput ?? {}
616
+ };
617
+ return new Set(
618
+ merged.categories ?? ["image", "document", "text", "other"]
619
+ );
620
+ }
438
621
  function defaultCapabilities(transport, runtime) {
622
+ const cliFileCategories = cliFileInputCategories(transport);
439
623
  const localOnly = runtime === "local" || transport.kind === "acp" || transport.kind === "cli" || transport.kind === "server";
440
624
  const browserSafe = !localOnly;
441
625
  return {
@@ -450,10 +634,12 @@ function defaultCapabilities(transport, runtime) {
450
634
  supportsClientToolExecution: transport.kind === "api",
451
635
  // C6 / F-P-4: api routes now advertise document + image input parity with acp.
452
636
  // The per-account `capabilities` override remains the escape hatch for
453
- // text-only api proxies that must opt out.
454
- supportsFileUpload: transport.kind === "acp" || transport.kind === "api",
637
+ // text-only api proxies that must opt out. REQ-024: a cli route advertises
638
+ // document/image input ONLY for the categories its fileInput actually stages
639
+ // (category-aware so e.g. an image-only cli cleanly rejects a PDF pre-spawn).
640
+ supportsFileUpload: transport.kind === "acp" || transport.kind === "api" || cliFileCategories.has("document"),
455
641
  supportsFileOutput: transport.kind === "acp",
456
- supportsImageInput: transport.kind === "acp" || transport.kind === "api",
642
+ supportsImageInput: transport.kind === "acp" || transport.kind === "api" || cliFileCategories.has("image"),
457
643
  supportsImageOutput: transport.kind === "acp",
458
644
  supportsProfileVerification: true,
459
645
  supportsCredentialRotation: false,
@@ -1004,6 +1190,10 @@ function extension(value) {
1004
1190
  }
1005
1191
  return name.slice(index + 1).toLowerCase();
1006
1192
  }
1193
+ function extensionForMime(mimeType) {
1194
+ const subtype = mimeType.split("/")[1]?.toLowerCase().replace(/[^a-z0-9.+-]/g, "");
1195
+ return subtype && subtype.length > 0 ? subtype : "bin";
1196
+ }
1007
1197
  function isRemoteUrl(value) {
1008
1198
  try {
1009
1199
  const url = new URL(value);
@@ -6342,112 +6532,6 @@ var AI_CONNECT_DEFAULT_ACP_COMMANDS = {
6342
6532
  "opencode:opencode-acp": "opencode acp"
6343
6533
  };
6344
6534
 
6345
- // src/cli-presets.ts
6346
- var AI_CONNECT_DEFAULT_CLI_PRESETS = {
6347
- pi: {
6348
- id: "pi",
6349
- label: "pi (coding agent)",
6350
- command: "pi",
6351
- transportId: "pi-cli",
6352
- options: {
6353
- preset: "pi",
6354
- // pi print mode emits the answer as plain text on stdout (no JSON wrapper),
6355
- // and has no ACP mode — so it uses the raw `text` parser and no discovery sidecar.
6356
- argsTemplate: ["--print", "--model", "{model}", "{prompt}"],
6357
- discovery: {
6358
- via: "none"
6359
- },
6360
- parser: {
6361
- kind: "text"
6362
- }
6363
- }
6364
- },
6365
- claude: {
6366
- id: "claude",
6367
- label: "Claude CLI",
6368
- command: "claude",
6369
- transportId: "claude-cli",
6370
- options: {
6371
- preset: "claude",
6372
- argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "{prompt}"],
6373
- discovery: {
6374
- via: "acp",
6375
- acp: {
6376
- transportId: "claude-code-acp"
6377
- }
6378
- },
6379
- parser: {
6380
- kind: "json",
6381
- textPath: "__preset__:claude.result",
6382
- usagePath: "usage",
6383
- errorPath: "__preset__:claude.error"
6384
- }
6385
- }
6386
- },
6387
- openclaude: {
6388
- id: "openclaude",
6389
- label: "OpenClaude CLI",
6390
- command: "openclaude",
6391
- transportId: "openclaude-cli",
6392
- options: {
6393
- preset: "openclaude",
6394
- argsTemplate: ["--print", "--output-format", "json", "--model", "{model}", "{prompt}"],
6395
- parser: {
6396
- kind: "json",
6397
- textPath: "__preset__:claude.result",
6398
- usagePath: "usage",
6399
- errorPath: "__preset__:claude.error"
6400
- }
6401
- }
6402
- },
6403
- codex: {
6404
- id: "codex",
6405
- label: "Codex CLI",
6406
- command: "codex",
6407
- transportId: "codex-cli",
6408
- options: {
6409
- preset: "codex",
6410
- argsTemplate: [
6411
- "exec",
6412
- "--json",
6413
- "--output-last-message",
6414
- "{output_file}",
6415
- "--model",
6416
- "{model}",
6417
- "{prompt}"
6418
- ],
6419
- discovery: {
6420
- via: "acp",
6421
- acp: {
6422
- transportId: "codex-acp"
6423
- }
6424
- },
6425
- parser: {
6426
- kind: "jsonl",
6427
- text: {
6428
- path: "__preset__:codex.text"
6429
- },
6430
- usage: {
6431
- path: "__preset__:codex.usage"
6432
- },
6433
- error: {
6434
- path: "__preset__:codex.error"
6435
- }
6436
- }
6437
- }
6438
- }
6439
- };
6440
- var AI_CONNECT_DEFAULT_CLI_COMMANDS = {
6441
- "anthropic:claude-cli": AI_CONNECT_DEFAULT_CLI_PRESETS.claude.command,
6442
- "anthropic:openclaude-cli": AI_CONNECT_DEFAULT_CLI_PRESETS.openclaude.command,
6443
- "openclaude:openclaude-cli": AI_CONNECT_DEFAULT_CLI_PRESETS.openclaude.command,
6444
- "openai:codex-cli": AI_CONNECT_DEFAULT_CLI_PRESETS.codex.command,
6445
- "pi:pi-cli": AI_CONNECT_DEFAULT_CLI_PRESETS.pi.command
6446
- };
6447
- function getCliTransportPreset(presetId) {
6448
- return AI_CONNECT_DEFAULT_CLI_PRESETS[presetId];
6449
- }
6450
-
6451
6535
  // src/server-presets.ts
6452
6536
  var AI_CONNECT_DEFAULT_SERVER_PRESETS = {
6453
6537
  opencode: {
@@ -8561,25 +8645,26 @@ function splitCommandLine2(commandLine) {
8561
8645
  function quoteArg(value) {
8562
8646
  return /\s/.test(value) ? JSON.stringify(value) : value;
8563
8647
  }
8564
- function buildCliPrompt(context) {
8648
+ function buildCliPrompt(context, fileInputEnabled) {
8565
8649
  if (context.request.operation !== "text") {
8566
8650
  throw new AiConnectError(
8567
8651
  "not_supported",
8568
8652
  `CLI transport "${context.route.transport.id}" only supports text requests.`
8569
8653
  );
8570
8654
  }
8571
- if (context.request.image || context.request.attachments.some(
8572
- (file) => portableFileCategory(file) === "image" || portableFileCategory(file) === "document"
8573
- )) {
8655
+ if (context.request.image) {
8574
8656
  throw new AiConnectError(
8575
8657
  "unsupported_capability",
8576
- `CLI transport "${context.route.transport.id}" does not support image or document inputs.`
8658
+ `CLI transport "${context.route.transport.id}" does not support image output.`
8577
8659
  );
8578
8660
  }
8579
- if (context.request.attachments.length > 0) {
8661
+ if (!fileInputEnabled && context.request.attachments.length > 0) {
8662
+ const hasBinary = context.request.attachments.some(
8663
+ (file) => portableFileCategory(file) === "image" || portableFileCategory(file) === "document"
8664
+ );
8580
8665
  throw new AiConnectError(
8581
- "not_supported",
8582
- `CLI transport "${context.route.transport.id}" does not support attachments.`
8666
+ hasBinary ? "unsupported_capability" : "not_supported",
8667
+ hasBinary ? `CLI transport "${context.route.transport.id}" does not support image or document inputs.` : `CLI transport "${context.route.transport.id}" does not support attachments.`
8583
8668
  );
8584
8669
  }
8585
8670
  return context.request.messages.map((message) => {
@@ -8724,11 +8809,16 @@ function createCliDiscoveryAcpRoute(route) {
8724
8809
  function resolveCliTransportOptions(route) {
8725
8810
  const presetId = route.transport.cli?.preset ?? defaultCliPresetIdForRoute(route);
8726
8811
  const preset = presetId ? getCliTransportPreset(presetId) : void 0;
8812
+ const mergedFileInput = preset?.options.fileInput || route.transport.cli?.fileInput ? {
8813
+ ...preset?.options.fileInput ?? {},
8814
+ ...route.transport.cli?.fileInput ?? {}
8815
+ } : void 0;
8727
8816
  const merged = {
8728
8817
  ...preset?.options ?? {},
8729
8818
  ...route.transport.cli ?? {},
8730
8819
  ...route.transport.cli?.argsTemplate ? { argsTemplate: route.transport.cli.argsTemplate } : {},
8731
8820
  ...route.transport.cli?.parser ? { parser: route.transport.cli.parser } : {},
8821
+ ...mergedFileInput ? { fileInput: mergedFileInput } : {},
8732
8822
  ...presetId ? { preset: presetId } : {}
8733
8823
  };
8734
8824
  if (!merged.argsTemplate?.length) {
@@ -8739,15 +8829,73 @@ function resolveCliTransportOptions(route) {
8739
8829
  }
8740
8830
  return merged;
8741
8831
  }
8742
- async function createOutputFile() {
8832
+ async function createCliTempDir(baseDir, prefix) {
8743
8833
  const tempDir = await fs2.mkdtemp(
8744
- path2.join(os2.tmpdir(), "ai-connect-cli-output-")
8834
+ path2.join(baseDir ?? os2.tmpdir(), prefix ?? "ai-connect-cli-")
8745
8835
  );
8746
8836
  return {
8747
8837
  tempDir,
8748
8838
  outputFile: path2.join(tempDir, "last-message.txt")
8749
8839
  };
8750
8840
  }
8841
+ function safeStagedBasename(name, index, mimeType) {
8842
+ const base = path2.basename(name.replace(/\\/g, "/"));
8843
+ if (base === "" || base === "." || base === "..") {
8844
+ return `attachment-${index}.${extensionForMime(mimeType)}`;
8845
+ }
8846
+ return base;
8847
+ }
8848
+ async function stageCliAttachments(attachments, attachDir, fileInput) {
8849
+ const categories = new Set(
8850
+ fileInput.categories ?? ["image", "document", "text", "other"]
8851
+ );
8852
+ await fs2.mkdir(attachDir, { recursive: true });
8853
+ const refs = [];
8854
+ const usedNames = /* @__PURE__ */ new Map();
8855
+ for (let i = 0; i < attachments.length; i += 1) {
8856
+ const file = preparePortableFile(attachments[i]);
8857
+ const payload = await materializePortableFile(file);
8858
+ if (!categories.has(payload.category)) {
8859
+ continue;
8860
+ }
8861
+ let destName = safeStagedBasename(payload.name, i, payload.mimeType);
8862
+ const seen = usedNames.get(destName);
8863
+ if (seen !== void 0) {
8864
+ destName = `${i}-${destName}`;
8865
+ }
8866
+ usedNames.set(destName, i);
8867
+ const dest = path2.join(attachDir, destName);
8868
+ if (payload.category === "text") {
8869
+ if (payload.text !== void 0) {
8870
+ await fs2.writeFile(dest, payload.text, "utf8");
8871
+ refs.push(dest);
8872
+ continue;
8873
+ }
8874
+ } else {
8875
+ const bytes = payload.base64 ? Buffer.from(payload.base64, "base64") : await portableFileToBytes(file);
8876
+ if (bytes !== void 0) {
8877
+ await fs2.writeFile(dest, bytes);
8878
+ refs.push(dest);
8879
+ continue;
8880
+ }
8881
+ }
8882
+ if (payload.uri) {
8883
+ refs.push(payload.uri);
8884
+ }
8885
+ }
8886
+ return refs;
8887
+ }
8888
+ function expandFileArgs(staged, fileInput) {
8889
+ const perFileArgs = fileInput?.perFileArgs ?? ["@{path}"];
8890
+ if (fileInput?.placement === "prompt") {
8891
+ return [];
8892
+ }
8893
+ return staged.flatMap(
8894
+ (ref) => perFileArgs.map(
8895
+ (tpl) => tpl.replace("{path}", ref).replace("{name}", path2.basename(ref))
8896
+ )
8897
+ );
8898
+ }
8751
8899
  function materializeTemplateArg(templateArg, values, parameterKeys) {
8752
8900
  switch (templateArg) {
8753
8901
  case "{prompt}":
@@ -8774,31 +8922,62 @@ function materializeTemplateArg(templateArg, values, parameterKeys) {
8774
8922
  }
8775
8923
  async function buildCliInvocation(context, options) {
8776
8924
  const route = context.route;
8777
- const prompt = buildCliPrompt(context);
8778
8925
  const cliOptions = resolveCliTransportOptions(route);
8926
+ const fileInput = cliOptions.fileInput;
8927
+ const attachments = context.request.attachments;
8928
+ const hasStageable = Boolean(fileInput) && attachments.length > 0;
8929
+ let prompt = buildCliPrompt(context, Boolean(fileInput));
8779
8930
  const commandLine = resolveCliCommand(route, options);
8780
8931
  const resolved = splitCommandLine2(commandLine);
8781
8932
  const parameterKeys = [];
8782
8933
  let tempDir;
8783
8934
  let outputFile;
8784
- if (cliOptions.argsTemplate.includes("{output_file}")) {
8785
- const created = await createOutputFile();
8935
+ const needsOutputFile = cliOptions.argsTemplate.includes("{output_file}");
8936
+ if (needsOutputFile || hasStageable) {
8937
+ const created = await createCliTempDir(
8938
+ fileInput?.stagingDir ?? options?.staging?.dir,
8939
+ options?.staging?.prefix
8940
+ );
8786
8941
  tempDir = created.tempDir;
8787
8942
  outputFile = created.outputFile;
8788
8943
  }
8944
+ let staged = [];
8945
+ if (hasStageable && tempDir && fileInput) {
8946
+ staged = await stageCliAttachments(
8947
+ attachments,
8948
+ path2.join(tempDir, "attachments"),
8949
+ fileInput
8950
+ );
8951
+ if (fileInput.placement === "prompt" && staged.length > 0) {
8952
+ const separator = fileInput.separator ?? " ";
8953
+ const mentionTemplate = fileInput.mentionTemplate ?? "@{path}";
8954
+ const mentions = staged.map(
8955
+ (ref) => mentionTemplate.replace("{path}", ref).replace("{name}", path2.basename(ref))
8956
+ ).join(separator);
8957
+ prompt = `${prompt}${separator}${mentions}`;
8958
+ }
8959
+ }
8789
8960
  const args = [
8790
8961
  ...resolved.args,
8791
- ...cliOptions.argsTemplate.map(
8792
- (part) => materializeTemplateArg(
8793
- part,
8794
- {
8795
- prompt,
8796
- model: route.model,
8797
- ...outputFile ? { outputFile } : {}
8798
- },
8799
- parameterKeys
8800
- )
8801
- )
8962
+ ...cliOptions.argsTemplate.flatMap((part) => {
8963
+ if (part === "{files}") {
8964
+ if (staged.length > 0) {
8965
+ parameterKeys.push("{files}");
8966
+ }
8967
+ return expandFileArgs(staged, fileInput);
8968
+ }
8969
+ return [
8970
+ materializeTemplateArg(
8971
+ part,
8972
+ {
8973
+ prompt,
8974
+ model: route.model,
8975
+ ...outputFile ? { outputFile } : {}
8976
+ },
8977
+ parameterKeys
8978
+ )
8979
+ ];
8980
+ })
8802
8981
  ];
8803
8982
  return {
8804
8983
  command: resolved.command,
@@ -8807,6 +8986,7 @@ async function buildCliInvocation(context, options) {
8807
8986
  env: buildCliEnvironment(options),
8808
8987
  ...tempDir ? { tempDir } : {},
8809
8988
  ...outputFile ? { outputFile } : {},
8989
+ ...options?.staging?.keep ? { keepStaging: true } : {},
8810
8990
  parameterKeys
8811
8991
  };
8812
8992
  }
@@ -9195,7 +9375,7 @@ async function executeCliInvocation(invocation, timeoutMs, phases, signal) {
9195
9375
  });
9196
9376
  }
9197
9377
  async function cleanupCliInvocation(invocation) {
9198
- if (invocation.tempDir) {
9378
+ if (invocation.tempDir && !invocation.keepStaging) {
9199
9379
  await fs2.rm(invocation.tempDir, { recursive: true, force: true });
9200
9380
  }
9201
9381
  }