cli-funnel 0.1.0 → 0.2.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/bin/cli.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  createFunnel
4
- } from "../chunk-2FZ64BNF.js";
4
+ } from "../chunk-CJLAAJHT.js";
5
5
  import {
6
6
  createHandler,
7
7
  serveNode
8
- } from "../chunk-GF7JASHO.js";
8
+ } from "../chunk-TBSSPWZE.js";
9
9
  import {
10
10
  ACCESS_LEVELS
11
- } from "../chunk-YP5V5YEE.js";
11
+ } from "../chunk-KMG3CM2J.js";
12
12
 
13
13
  // src/bin/cli.ts
14
14
  import { createInterface } from "readline/promises";
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ACCESS_LEVELS,
3
3
  FunnelError
4
- } from "./chunk-YP5V5YEE.js";
4
+ } from "./chunk-KMG3CM2J.js";
5
5
 
6
6
  // src/catalog/manifest.ts
7
7
  import { existsSync } from "fs";
@@ -126,7 +126,14 @@ var anthropic = {
126
126
  method: "POST",
127
127
  signal: input.signal,
128
128
  headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json" },
129
- body: JSON.stringify({ model: input.selection.model, max_tokens: 8192, stream: true, messages })
129
+ body: JSON.stringify({
130
+ model: input.selection.model,
131
+ max_tokens: input.maxOutputTokens ?? 8192,
132
+ stream: true,
133
+ ...input.system ? { system: input.system } : {},
134
+ ...input.responseSchema ? { output_config: { format: { type: "json_schema", schema: input.responseSchema.schema } } } : {},
135
+ messages
136
+ })
130
137
  });
131
138
  if (!res.ok) return yield { type: "error", message: `Anthropic API ${res.status}: ${await res.text()}`, code: "cli-failed" };
132
139
  let inTok = 0;
@@ -146,6 +153,13 @@ var anthropic = {
146
153
  }
147
154
  yield { type: "usage", usage: { inputTokens: inTok, outputTokens: outTok, cachedInputTokens: cached, totalTokens: inTok + outTok } };
148
155
  yield { type: "done", text: "", finishReason: stop === "refusal" ? "denied" : "stop" };
156
+ },
157
+ userContent(input) {
158
+ if (!input.attachments?.length) return input.prompt;
159
+ return [
160
+ ...input.attachments.map((a) => ({ type: "image", source: { type: "base64", media_type: a.mediaType, data: a.data } })),
161
+ { type: "text", text: input.prompt }
162
+ ];
149
163
  }
150
164
  };
151
165
  var openai = {
@@ -164,7 +178,15 @@ var openai = {
164
178
  method: "POST",
165
179
  signal: input.signal,
166
180
  headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
167
- body: JSON.stringify({ model: input.selection.model, messages, stream: true, stream_options: { include_usage: true } })
181
+ body: JSON.stringify({
182
+ model: input.selection.model,
183
+ // The system message is sent on every call and never stored in the session history.
184
+ messages: input.system ? [{ role: "system", content: input.system }, ...messages] : messages,
185
+ stream: true,
186
+ stream_options: { include_usage: true },
187
+ ...input.maxOutputTokens ? { max_completion_tokens: input.maxOutputTokens } : {},
188
+ ...input.responseSchema ? { response_format: { type: "json_schema", json_schema: { name: input.responseSchema.name ?? "response", schema: input.responseSchema.schema } } } : {}
189
+ })
168
190
  });
169
191
  if (!res.ok) return yield { type: "error", message: `OpenAI API ${res.status}: ${await res.text()}`, code: "cli-failed" };
170
192
  let usage;
@@ -179,6 +201,13 @@ var openai = {
179
201
  }
180
202
  if (usage) yield { type: "usage", usage: { inputTokens: usage.prompt_tokens, outputTokens: usage.completion_tokens, totalTokens: usage.total_tokens } };
181
203
  yield { type: "done", text: "", finishReason: stop === "content_filter" ? "denied" : "stop" };
204
+ },
205
+ userContent(input) {
206
+ if (!input.attachments?.length) return input.prompt;
207
+ return [
208
+ { type: "text", text: input.prompt },
209
+ ...input.attachments.map((a) => ({ type: "image_url", image_url: { url: `data:${a.mediaType};base64,${a.data}` } }))
210
+ ];
182
211
  }
183
212
  };
184
213
  var histories = /* @__PURE__ */ new Map();
@@ -192,7 +221,17 @@ function makeApiProvider(spec, getKey) {
192
221
  id: spec.id,
193
222
  displayName: spec.displayName,
194
223
  binary: "fetch",
195
- capabilities: { access: [], effort: false, contextWindow: false, fast: false, resume: true, approvals: false },
224
+ capabilities: {
225
+ access: [],
226
+ effort: false,
227
+ contextWindow: false,
228
+ fast: false,
229
+ resume: true,
230
+ approvals: false,
231
+ images: true,
232
+ system: "native",
233
+ schema: "native"
234
+ },
196
235
  detect: async () => ({ installed: true, testedRange: { min: "0" }, withinTestedRange: true }),
197
236
  authStatus: async () => ({ loggedIn: !!(getKey() ?? process.env[spec.envVar]), method: "API key" }),
198
237
  login() {
@@ -213,7 +252,7 @@ function makeApiProvider(spec, getKey) {
213
252
  models: async () => spec.listModels(key()),
214
253
  async *run(input) {
215
254
  const sessionId = input.sessionId ?? randomUUID();
216
- const messages = [...histories.get(sessionId) ?? [], { role: "user", content: input.prompt }];
255
+ const messages = [...histories.get(sessionId) ?? [], { role: "user", content: spec.userContent(input) }];
217
256
  yield { type: "session", sessionId, model: input.selection.model };
218
257
  let text = "";
219
258
  for await (const e of spec.stream(key(), input, messages)) {
@@ -267,7 +306,7 @@ var Channel = class {
267
306
  import { spawn as spawn2 } from "child_process";
268
307
 
269
308
  // src/util/process.ts
270
- import { spawn } from "child_process";
309
+ import { execFileSync, spawn } from "child_process";
271
310
  import { existsSync as existsSync3, accessSync, constants } from "fs";
272
311
  import { homedir } from "os";
273
312
  import { delimiter, join } from "path";
@@ -279,10 +318,35 @@ var EXTRA_DIRS = [
279
318
  "/opt/homebrew/bin",
280
319
  "/usr/local/bin"
281
320
  ];
321
+ var MARK = "__CLI_FUNNEL_ENV__";
322
+ var shellPath;
323
+ function loginShellPath(refresh = false) {
324
+ if (shellPath && !refresh) return shellPath;
325
+ shellPath = [];
326
+ if (process.platform === "win32" || process.env.CLI_FUNNEL_NO_SHELL_PATH === "1") return shellPath;
327
+ const shell = process.env.SHELL || (process.platform === "darwin" ? "/bin/zsh" : "/bin/sh");
328
+ try {
329
+ const out = execFileSync(shell, ["-ilc", `echo ${MARK}; /usr/bin/env; echo ${MARK}`], {
330
+ encoding: "utf8",
331
+ timeout: 5e3,
332
+ stdio: ["ignore", "pipe", "ignore"]
333
+ });
334
+ const line = out.split(MARK)[1]?.split("\n").find((l) => l.startsWith("PATH="));
335
+ if (line) shellPath = line.slice(5).split(":").filter(Boolean);
336
+ } catch {
337
+ }
338
+ return shellPath;
339
+ }
340
+ function searchPath() {
341
+ return [.../* @__PURE__ */ new Set([...(process.env.PATH ?? "").split(delimiter).filter(Boolean), ...loginShellPath(), ...EXTRA_DIRS])];
342
+ }
343
+ function childEnv(extra) {
344
+ return { ...process.env, PATH: searchPath().join(delimiter), ...extra };
345
+ }
282
346
  function resolveBinary(name, envVar) {
283
347
  const override = envVar ? process.env[envVar] : void 0;
284
348
  if (override && existsSync3(override)) return override;
285
- const dirs = [...(process.env.PATH ?? "").split(delimiter), ...EXTRA_DIRS];
349
+ const dirs = searchPath();
286
350
  const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
287
351
  for (const dir of dirs) {
288
352
  for (const ext of exts) {
@@ -300,7 +364,7 @@ function exec(file, args, opts = {}) {
300
364
  return new Promise((resolve, reject) => {
301
365
  const child = spawn(file, args, {
302
366
  cwd: opts.cwd,
303
- env: { ...process.env, ...opts.env },
367
+ env: childEnv(opts.env),
304
368
  stdio: ["pipe", "pipe", "pipe"],
305
369
  signal: opts.signal
306
370
  });
@@ -325,7 +389,7 @@ function exec(file, args, opts = {}) {
325
389
  function spawnStream(file, args, opts = {}) {
326
390
  const child = spawn(file, args, {
327
391
  cwd: opts.cwd,
328
- env: { ...process.env, ...opts.env },
392
+ env: childEnv(opts.env),
329
393
  stdio: ["pipe", "pipe", "pipe"]
330
394
  });
331
395
  const channel = new Channel();
@@ -397,6 +461,26 @@ function compareVersions(a, b) {
397
461
  return 0;
398
462
  }
399
463
 
464
+ // src/providers/prompt.ts
465
+ function composePrompt(input, opts) {
466
+ const parts = [];
467
+ if (opts.system && input.system) parts.push(`<instructions>
468
+ ${input.system}
469
+ </instructions>`);
470
+ parts.push(input.prompt);
471
+ if (opts.schema && input.responseSchema) {
472
+ parts.push(
473
+ "Reply with only a JSON value that matches this JSON Schema. No prose, no code fences.\n" + JSON.stringify(input.responseSchema.schema)
474
+ );
475
+ }
476
+ return parts.join("\n\n");
477
+ }
478
+ function parseJsonAnswer(text) {
479
+ const trimmed = text.trim();
480
+ const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n?```$/i.exec(trimmed);
481
+ return JSON.parse(fenced ? fenced[1] : trimmed);
482
+ }
483
+
400
484
  // src/providers/base.ts
401
485
  function binaryEnvVar(binary) {
402
486
  return `CLI_FUNNEL_${binary.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_BIN`;
@@ -420,7 +504,7 @@ function spawnLogin(binary, args, readStatus, opts = {}) {
420
504
  channel.push({ type: "error", message: `${binary} is not installed.` });
421
505
  channel.end();
422
506
  } else {
423
- child = spawn2(path, args, { env: { ...process.env, ...opts.env }, signal: opts.signal, stdio: ["pipe", "pipe", "pipe"] });
507
+ child = spawn2(path, args, { env: childEnv(opts.env), signal: opts.signal, stdio: ["pipe", "pipe", "pipe"] });
424
508
  child.stdin?.on("error", () => {
425
509
  });
426
510
  const seen = /* @__PURE__ */ new Set();
@@ -500,6 +584,9 @@ async function collect(events, input) {
500
584
  case "usage":
501
585
  result.usage = e.usage;
502
586
  break;
587
+ case "structured":
588
+ result.structured = e.data;
589
+ break;
503
590
  case "done":
504
591
  if (e.text) result.text = e.text;
505
592
  result.finishReason = e.finishReason;
@@ -509,6 +596,13 @@ async function collect(events, input) {
509
596
  throw Object.assign(new Error(e.message), { code: e.code });
510
597
  }
511
598
  }
599
+ if (input.responseSchema && result.structured === void 0 && result.finishReason === "stop") {
600
+ try {
601
+ result.structured = parseJsonAnswer(result.text);
602
+ } catch (err) {
603
+ result.structuredError = `The answer is not JSON: ${err instanceof Error ? err.message : String(err)}`;
604
+ }
605
+ }
512
606
  return result;
513
607
  }
514
608
 
@@ -570,6 +664,7 @@ function parseResult(r) {
570
664
  };
571
665
  events.push({ type: "usage", usage });
572
666
  }
667
+ if (r.structured_output !== void 0) events.push({ type: "structured", data: r.structured_output });
573
668
  const denied = Array.isArray(r.permission_denials) && r.permission_denials.length > 0;
574
669
  const finishReason = denied ? "denied" : "stop";
575
670
  events.push({ type: "done", text, finishReason });
@@ -580,19 +675,25 @@ function parseResult(r) {
580
675
  var BIN = "claude";
581
676
  var TESTED_RANGE = { min: "2.1.0" };
582
677
  var PERMISSION_MODE = {
678
+ // With every tool removed there is nothing to approve. `manual` keeps it that way if a tool slips in.
679
+ none: "manual",
583
680
  supervised: "manual",
584
681
  "accept-edits": "acceptEdits",
585
682
  auto: "auto",
586
683
  full: "bypassPermissions"
587
684
  };
588
685
  var capabilities = {
589
- access: ["supervised", "accept-edits", "auto", "full"],
686
+ access: ["none", "supervised", "accept-edits", "auto", "full"],
590
687
  effort: true,
591
688
  contextWindow: false,
592
689
  fast: false,
593
690
  resume: true,
594
- approvals: true
691
+ approvals: true,
692
+ images: true,
693
+ system: "native",
694
+ schema: "native"
595
695
  };
696
+ var NO_TOOLS = ["--tools", "", "--strict-mcp-config", "--setting-sources", ""];
596
697
  function buildArgs(input) {
597
698
  const { selection } = input;
598
699
  const args = [
@@ -609,10 +710,20 @@ function buildArgs(input) {
609
710
  PERMISSION_MODE[selection.access]
610
711
  ];
611
712
  if (selection.effort) args.push("--effort", selection.effort);
612
- if (selection.access !== "full" && input.onApproval) args.push("--permission-prompt-tool", "stdio");
713
+ if (selection.access === "none") args.push(...NO_TOOLS);
714
+ if (input.system) args.push(selection.access === "none" ? "--system-prompt" : "--append-system-prompt", input.system);
715
+ if (input.responseSchema) args.push("--json-schema", JSON.stringify(input.responseSchema.schema));
716
+ if (selection.access !== "full" && selection.access !== "none" && input.onApproval) args.push("--permission-prompt-tool", "stdio");
613
717
  if (input.sessionId) args.push("--resume", input.sessionId);
614
718
  return args;
615
719
  }
720
+ function userContent(input) {
721
+ if (!input.attachments?.length) return input.prompt;
722
+ return [
723
+ ...input.attachments.map((a) => ({ type: "image", source: { type: "base64", media_type: a.mediaType, data: a.data } })),
724
+ { type: "text", text: input.prompt }
725
+ ];
726
+ }
616
727
  async function* run(input) {
617
728
  const bin = findBinary(BIN);
618
729
  if (!bin) throw new FunnelError("Claude Code is not installed.", "not-installed");
@@ -620,7 +731,7 @@ async function* run(input) {
620
731
  throw new FunnelError('Access "supervised" needs an onApproval handler.', "invalid-selection");
621
732
  }
622
733
  const proc = spawnStream(bin, buildArgs(input), { cwd: input.selection.cwd, env: input.env, signal: input.signal, closeStdin: false });
623
- proc.write(JSON.stringify({ type: "user", message: { role: "user", content: input.prompt } }));
734
+ proc.write(JSON.stringify({ type: "user", message: { role: "user", content: userContent(input) } }));
624
735
  let finished = false;
625
736
  try {
626
737
  for await (const raw of proc.lines) {
@@ -683,6 +794,8 @@ var claudeProvider = {
683
794
  // src/providers/codex/access.ts
684
795
  function planAccess(access) {
685
796
  switch (access) {
797
+ case "none":
798
+ return { approvalPolicy: "untrusted", sandbox: "read-only", approvalsReviewer: "user", autoAllowFileChanges: false };
686
799
  case "supervised":
687
800
  return { approvalPolicy: "untrusted", sandbox: "workspace-write", approvalsReviewer: "user", autoAllowFileChanges: false };
688
801
  case "accept-edits":
@@ -892,6 +1005,60 @@ var RpcClient = class {
892
1005
  }
893
1006
  };
894
1007
 
1008
+ // src/providers/codex/tools.ts
1009
+ import { readFileSync } from "fs";
1010
+ import { homedir as homedir2 } from "os";
1011
+ import { join as join2 } from "path";
1012
+ var TOOL_FEATURES = [
1013
+ "shell_tool",
1014
+ "unified_exec",
1015
+ "apps",
1016
+ "plugins",
1017
+ "multi_agent",
1018
+ "multi_agent_v2",
1019
+ "browser_use",
1020
+ "computer_use",
1021
+ "image_generation",
1022
+ "hooks",
1023
+ "skill_search",
1024
+ "tool_suggest",
1025
+ "goals",
1026
+ "sleep_tool",
1027
+ "code_mode_host",
1028
+ "memories",
1029
+ "in_app_browser"
1030
+ ];
1031
+ var SERVER_HEADER = /^\s*\[mcp_servers\.(?:"([^"]+)"|([A-Za-z0-9_-]+))\]\s*$/gm;
1032
+ function declaredServers(file) {
1033
+ let text;
1034
+ try {
1035
+ text = readFileSync(file, "utf8");
1036
+ } catch {
1037
+ return [];
1038
+ }
1039
+ return [...text.matchAll(SERVER_HEADER)].map((m) => m[1] ?? m[2]);
1040
+ }
1041
+ async function configMcpServers(bin, cwd) {
1042
+ const res = await exec(bin, ["mcp", "list", "--json"], { timeoutMs: 2e4 });
1043
+ if (res.code !== 0) throw new Error(`codex mcp list failed: ${(res.stderr || res.stdout).trim().slice(0, 200)}`);
1044
+ const enabled = new Set(
1045
+ JSON.parse(res.stdout).filter((s) => s.enabled !== false).map((s) => s.name)
1046
+ );
1047
+ const home = process.env.CODEX_HOME ?? join2(homedir2(), ".codex");
1048
+ const declared = [...declaredServers(join2(home, "config.toml")), ...declaredServers(join2(cwd, ".codex", "config.toml"))];
1049
+ return [...new Set(declared)].filter((name) => enabled.has(name));
1050
+ }
1051
+ async function noToolsConfig(bin, cwd) {
1052
+ const servers = await configMcpServers(bin, cwd);
1053
+ return Object.fromEntries([
1054
+ ...TOOL_FEATURES.map((f) => [`features.${f}`, false]),
1055
+ ["web_search", "disabled"],
1056
+ ["include_apply_patch_tool", false],
1057
+ ["tools.view_image", false],
1058
+ ...servers.map((name) => [`mcp_servers.${name}.enabled`, false])
1059
+ ]);
1060
+ }
1061
+
895
1062
  // src/providers/codex/index.ts
896
1063
  var BINARY = "codex";
897
1064
  var TESTED_RANGE2 = { min: "0.150.0" };
@@ -935,6 +1102,7 @@ async function* run2(input) {
935
1102
  proc.child.kill("SIGTERM");
936
1103
  };
937
1104
  const decide = async (req) => {
1105
+ if (sel.access === "none") return "deny";
938
1106
  if (req.method === "item/fileChange/requestApproval" && plan.autoAllowFileChanges) return "allow";
939
1107
  if (sel.access === "full") return "allow";
940
1108
  const request = toApprovalRequest(req, translator?.items ?? /* @__PURE__ */ new Map());
@@ -993,7 +1161,9 @@ async function* run2(input) {
993
1161
  approvalPolicy: plan.approvalPolicy,
994
1162
  approvalsReviewer: plan.approvalsReviewer,
995
1163
  sandbox: plan.sandbox,
996
- ...sel.fast ? { serviceTier: "priority" } : {}
1164
+ ...sel.fast ? { serviceTier: "priority" } : {},
1165
+ ...input.system ? { developerInstructions: input.system } : {},
1166
+ ...sel.access === "none" ? { config: await noToolsConfig(path, sel.cwd) } : {}
997
1167
  };
998
1168
  const started = input.sessionId ? await rpc.request("thread/resume", { threadId: input.sessionId, ...settings }) : await rpc.request("thread/start", settings);
999
1169
  threadId = started.thread.id;
@@ -1001,8 +1171,12 @@ async function* run2(input) {
1001
1171
  out.push({ type: "session", sessionId: threadId, model: started.model });
1002
1172
  const res = await rpc.request("turn/start", {
1003
1173
  threadId,
1004
- input: [{ type: "text", text: input.prompt, text_elements: [] }],
1005
- ...sel.effort ? { effort: sel.effort } : {}
1174
+ input: [
1175
+ ...(input.attachments ?? []).map((a) => ({ type: "image", url: `data:${a.mediaType};base64,${a.data}` })),
1176
+ { type: "text", text: input.prompt, text_elements: [] }
1177
+ ],
1178
+ ...sel.effort ? { effort: sel.effort } : {},
1179
+ ...input.responseSchema ? { outputSchema: input.responseSchema.schema } : {}
1006
1180
  });
1007
1181
  turnId ??= res?.turn?.id;
1008
1182
  } catch (e) {
@@ -1021,12 +1195,15 @@ var codexProvider = {
1021
1195
  displayName: "Codex",
1022
1196
  binary: BINARY,
1023
1197
  capabilities: {
1024
- access: ["supervised", "accept-edits", "auto", "full"],
1198
+ access: ["none", "supervised", "accept-edits", "auto", "full"],
1025
1199
  effort: true,
1026
1200
  contextWindow: false,
1027
1201
  fast: true,
1028
1202
  resume: true,
1029
- approvals: true
1203
+ approvals: true,
1204
+ images: true,
1205
+ system: "native",
1206
+ schema: "native"
1030
1207
  },
1031
1208
  detect: () => detectInstallation(BINARY, TESTED_RANGE2),
1032
1209
  authStatus: authStatus2,
@@ -1271,7 +1448,7 @@ function buildArgs2(input, model) {
1271
1448
  ...accessFlags(selection.access),
1272
1449
  ...input.sessionId ? ["--resume", input.sessionId] : [],
1273
1450
  "--",
1274
- input.prompt
1451
+ composePrompt(input, { system: true, schema: true })
1275
1452
  ];
1276
1453
  }
1277
1454
  async function authStatus3() {
@@ -1314,7 +1491,18 @@ var agentProvider = {
1314
1491
  id: "agent",
1315
1492
  displayName: "Cursor Agent",
1316
1493
  binary: BINARY2,
1317
- capabilities: { access: ["auto", "full"], effort: true, contextWindow: false, fast: true, resume: true, approvals: false },
1494
+ // No `none`: in testing, `--mode ask` with `--sandbox enabled` still ran shell commands, and only the model's own refusal stopped writes.
1495
+ capabilities: {
1496
+ access: ["auto", "full"],
1497
+ effort: true,
1498
+ contextWindow: false,
1499
+ fast: true,
1500
+ resume: true,
1501
+ approvals: false,
1502
+ images: false,
1503
+ system: "prompt",
1504
+ schema: "prompt"
1505
+ },
1318
1506
  detect: () => detectInstallation(BINARY2, TESTED_RANGE3),
1319
1507
  authStatus: authStatus3,
1320
1508
  login: (options) => spawnLogin(BINARY2, ["login"], authStatus3, {
@@ -1336,8 +1524,8 @@ var agentProvider = {
1336
1524
 
1337
1525
  // src/providers/antigravity/index.ts
1338
1526
  import { existsSync as existsSync4 } from "fs";
1339
- import { homedir as homedir2 } from "os";
1340
- import { join as join2 } from "path";
1527
+ import { homedir as homedir3 } from "os";
1528
+ import { join as join3 } from "path";
1341
1529
 
1342
1530
  // src/providers/antigravity/parser.ts
1343
1531
  var EFFORTS = ["low", "medium", "high", "max"];
@@ -1438,6 +1626,7 @@ function createMapper() {
1438
1626
  });
1439
1627
  const usage = toUsage2(r.usage);
1440
1628
  if (usage) events.push({ type: "usage", usage });
1629
+ if (r.structured_output !== void 0) events.push({ type: "structured", data: r.structured_output });
1441
1630
  const text = r.response ?? "";
1442
1631
  events.push({ type: "done", text, finishReason: denied.length && !text.trim() ? "denied" : "stop" });
1443
1632
  return events;
@@ -1448,7 +1637,7 @@ function createMapper() {
1448
1637
  }
1449
1638
 
1450
1639
  // src/providers/antigravity/index.ts
1451
- var TOKEN_FILE = join2(homedir2(), ".gemini", "antigravity-cli", "antigravity-oauth-token");
1640
+ var TOKEN_FILE = join3(homedir3(), ".gemini", "antigravity-cli", "antigravity-oauth-token");
1452
1641
  var TESTED = { min: "1.2.0" };
1453
1642
  function toCliModel2(selection, models2 = []) {
1454
1643
  const info = models2.find((m) => m.id === selection.model);
@@ -1458,7 +1647,8 @@ function toCliModel2(selection, models2 = []) {
1458
1647
  function buildArgs3(input, models2 = []) {
1459
1648
  const { selection } = input;
1460
1649
  const { model, effort } = toCliModel2(selection, models2);
1461
- const args = ["--print=" + input.prompt, "--output-format", "stream-json", "--model", model];
1650
+ const args = ["--print=" + composePrompt(input, { system: true, schema: false }), "--output-format", "stream-json", "--model", model];
1651
+ if (input.responseSchema) args.push("--json-schema", JSON.stringify(input.responseSchema.schema));
1462
1652
  if (effort) args.push("--effort", effort);
1463
1653
  if (selection.access === "accept-edits") args.push("--mode", "accept-edits");
1464
1654
  if (selection.access === "full") args.push("--dangerously-skip-permissions");
@@ -1527,7 +1717,18 @@ var antigravityProvider = {
1527
1717
  id: "antigravity",
1528
1718
  displayName: "Antigravity",
1529
1719
  binary: "agy",
1530
- capabilities: { access: ["accept-edits", "full"], effort: true, contextWindow: false, fast: false, resume: true, approvals: false },
1720
+ // No `none`: in testing, `--mode plan` with `--sandbox` still let a shell command write outside the workspace.
1721
+ capabilities: {
1722
+ access: ["accept-edits", "full"],
1723
+ effort: true,
1724
+ contextWindow: false,
1725
+ fast: false,
1726
+ resume: true,
1727
+ approvals: false,
1728
+ images: false,
1729
+ system: "prompt",
1730
+ schema: "native"
1731
+ },
1531
1732
  detect: () => detectInstallation("agy", TESTED),
1532
1733
  authStatus: authStatus4,
1533
1734
  login,
@@ -1575,6 +1776,9 @@ function createFunnel(options = {}) {
1575
1776
  if (input.selection.access === "supervised" && !input.onApproval) {
1576
1777
  throw new FunnelError('access "supervised" needs an onApproval callback.', "invalid-selection");
1577
1778
  }
1779
+ if (input.attachments?.length && !p.capabilities.images) {
1780
+ throw new FunnelError(`${p.displayName} cannot take image attachments.`, "unsupported");
1781
+ }
1578
1782
  return p;
1579
1783
  }
1580
1784
  function stream(input) {
@@ -1,5 +1,5 @@
1
1
  // src/types.ts
2
- var ACCESS_LEVELS = ["supervised", "accept-edits", "auto", "full"];
2
+ var ACCESS_LEVELS = ["none", "supervised", "accept-edits", "auto", "full"];
3
3
  var FunnelError = class extends Error {
4
4
  constructor(message, code) {
5
5
  super(message);
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ACCESS_LEVELS,
3
3
  FunnelError
4
- } from "./chunk-YP5V5YEE.js";
4
+ } from "./chunk-KMG3CM2J.js";
5
5
 
6
6
  // src/server/handler.ts
7
7
  import { timingSafeEqual } from "crypto";
@@ -50,6 +50,15 @@ function sse(items, opts = {}) {
50
50
 
51
51
  // src/server/openai.ts
52
52
  var text = (c) => typeof c === "string" ? c : (c ?? []).map((p) => p.type === "text" ? p.text ?? "" : "").join("");
53
+ var IMAGE_DATA_URL = /^data:(image\/(?:png|jpeg|webp|gif));base64,(.+)$/;
54
+ function toAttachments(messages) {
55
+ const last = [...messages].reverse().find((m) => m.role === "user");
56
+ if (!last || typeof last.content === "string" || !last.content) return [];
57
+ return last.content.flatMap((p) => {
58
+ const m = p.type === "image_url" && p.image_url ? IMAGE_DATA_URL.exec(p.image_url.url) : null;
59
+ return m ? [{ type: "image", mediaType: m[1], data: m[2] }] : [];
60
+ });
61
+ }
53
62
  function toPrompt(messages, resumed) {
54
63
  if (resumed) return text([...messages].reverse().find((m) => m.role === "user")?.content ?? "");
55
64
  if (messages.length === 1) return text(messages[0].content);
@@ -86,7 +95,12 @@ async function handleOpenAI(funnel, req, path, defaults = {}, cwdAllowed = () =>
86
95
  cwd,
87
96
  access
88
97
  },
89
- prompt: toPrompt(body.messages, !!body.x_funnel?.sessionId),
98
+ // System messages become the run's system prompt instead of a line in the flattened chat.
99
+ prompt: toPrompt(body.messages.filter((m) => m.role !== "system"), !!body.x_funnel?.sessionId),
100
+ system: body.messages.filter((m) => m.role === "system").map((m) => text(m.content)).join("\n\n") || void 0,
101
+ attachments: toAttachments(body.messages),
102
+ responseSchema: body.response_format?.type === "json_schema" && body.response_format.json_schema?.schema ? { name: body.response_format.json_schema.name, schema: body.response_format.json_schema.schema } : void 0,
103
+ maxOutputTokens: body.max_completion_tokens ?? body.max_tokens,
90
104
  sessionId: body.x_funnel?.sessionId,
91
105
  signal: req.signal,
92
106
  onApproval: () => "deny"
@@ -160,6 +174,10 @@ function createHandler(funnel, options = {}) {
160
174
  const input = {
161
175
  selection: body.selection,
162
176
  prompt: body.prompt,
177
+ system: body.system,
178
+ attachments: body.attachments,
179
+ responseSchema: body.responseSchema,
180
+ maxOutputTokens: body.maxOutputTokens,
163
181
  sessionId: body.sessionId,
164
182
  signal: req.signal,
165
183
  onApproval: (request) => new Promise((resolveDecision) => {
@@ -1,5 +1,5 @@
1
- import { P as ProviderOverview, a as ProviderId, M as ModelInfo, A as AuthStatus, U as UpdateResult, L as LoginEvent, R as RunInput, S as Selection, F as FunnelEvent, b as ApprovalDecision } from '../funnel-CoOj5wr1.js';
2
- export { c as ACCESS_LEVELS, d as AccessLevel, e as ApiProviderId, f as ApprovalRequest, C as Capabilities, g as CliProviderId, E as EffortOption, h as FinishReason, i as FunnelError, I as Installation, j as LoginSession, k as Provider, l as RunResult, m as Usage } from '../funnel-CoOj5wr1.js';
1
+ import { P as ProviderOverview, a as ProviderId, M as ModelInfo, A as AuthStatus, U as UpdateResult, L as LoginEvent, R as RunInput, S as Selection, F as FunnelEvent, b as ApprovalDecision } from '../funnel-BZBIXenY.js';
2
+ export { c as ACCESS_LEVELS, d as AccessLevel, e as ApiProviderId, f as ApprovalRequest, g as Attachment, C as Capabilities, h as CliProviderId, E as EffortOption, i as FinishReason, j as FunnelError, I as ImageAttachment, k as Installation, l as LoginSession, m as Provider, n as ResponseSchema, o as RunResult, p as Usage } from '../funnel-BZBIXenY.js';
3
3
 
4
4
  interface ClientOptions {
5
5
  /** Where the handler is mounted, for example "/api/funnel" or "http://localhost:4747". */
@@ -40,7 +40,7 @@ declare function createClient(options: ClientOptions): {
40
40
  ok: boolean;
41
41
  }>;
42
42
  /** Streams run events. The first event is `{ type: "run", runId }`, needed to answer approvals. */
43
- run: (input: Pick<RunInput, "prompt" | "sessionId"> & {
43
+ run: (input: Pick<RunInput, "prompt" | "sessionId" | "system" | "attachments" | "responseSchema" | "maxOutputTokens"> & {
44
44
  selection: Selection;
45
45
  }, signal?: AbortSignal) => AsyncGenerator<RunEvent, any, any>;
46
46
  approve: (runId: string, approvalId: string, decision: ApprovalDecision) => Promise<{
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ACCESS_LEVELS,
3
3
  FunnelError
4
- } from "../chunk-YP5V5YEE.js";
4
+ } from "../chunk-KMG3CM2J.js";
5
5
 
6
6
  // src/client/index.ts
7
7
  function createClient(options) {
@@ -1,8 +1,11 @@
1
1
  type CliProviderId = "claude" | "codex" | "agent" | "antigravity";
2
2
  type ApiProviderId = "anthropic-api" | "openai-api";
3
3
  type ProviderId = CliProviderId | ApiProviderId;
4
- /** How much the agent may do on the machine. Providers list the levels they can really enforce. */
5
- type AccessLevel = "supervised" | "accept-edits" | "auto" | "full";
4
+ /**
5
+ * How much the agent may do on the machine. Providers list the levels they can really enforce.
6
+ * `none` is text in, text out: no file writes, no commands, no MCP servers, no approvals.
7
+ */
8
+ type AccessLevel = "none" | "supervised" | "accept-edits" | "auto" | "full";
6
9
  declare const ACCESS_LEVELS: readonly AccessLevel[];
7
10
  interface EffortOption {
8
11
  /** Value passed to the CLI, for example "high". */
@@ -32,6 +35,12 @@ interface Capabilities {
32
35
  fast: boolean;
33
36
  resume: boolean;
34
37
  approvals: boolean;
38
+ /** Accepts image attachments. */
39
+ images: boolean;
40
+ /** `native` when the CLI or API takes a system prompt. `prompt` means cli-funnel puts it in front of the prompt. */
41
+ system: "native" | "prompt";
42
+ /** `native` when the CLI or API enforces `responseSchema`. `prompt` means the schema is only asked for in the prompt. */
43
+ schema: "native" | "prompt";
35
44
  }
36
45
  /** The whole UI state in one serializable object. Hardcode it or let the UI produce it. */
37
46
  interface Selection {
@@ -53,6 +62,19 @@ interface Usage {
53
62
  /** Set only when the CLI reports it. Subscription runs often do not. */
54
63
  costUsd?: number;
55
64
  }
65
+ /** An image sent with the prompt. `data` is base64 without a `data:` prefix. */
66
+ interface ImageAttachment {
67
+ type: "image";
68
+ mediaType: "image/png" | "image/jpeg" | "image/webp" | "image/gif";
69
+ data: string;
70
+ }
71
+ type Attachment = ImageAttachment;
72
+ /** A JSON Schema for the final answer. Anthropic and Codex need `additionalProperties: false` on every object. */
73
+ interface ResponseSchema {
74
+ /** Short name, used by APIs that label schemas. Default "response". */
75
+ name?: string;
76
+ schema: Record<string, unknown>;
77
+ }
56
78
  type FinishReason = "stop" | "cancelled" | "error" | "denied";
57
79
  interface ApprovalRequest {
58
80
  id: string;
@@ -90,6 +112,11 @@ type FunnelEvent = {
90
112
  } | {
91
113
  type: "usage";
92
114
  usage: Usage;
115
+ }
116
+ /** The parsed answer when the provider reports structured output itself. */
117
+ | {
118
+ type: "structured";
119
+ data: unknown;
93
120
  } | {
94
121
  type: "done";
95
122
  text: string;
@@ -116,10 +143,22 @@ interface RunResult {
116
143
  }[];
117
144
  /** Tool calls the CLI refused because nobody could approve them. */
118
145
  deniedActions: string[];
146
+ /** The parsed answer when `responseSchema` was set. Not validated against the schema: do that yourself. */
147
+ structured?: unknown;
148
+ /** Why `structured` is missing when `responseSchema` was set, for example text that is not JSON. */
149
+ structuredError?: string;
119
150
  }
120
151
  interface RunInput {
121
152
  selection: Selection;
122
153
  prompt: string;
154
+ /** Instructions for the whole run. See `capabilities.system` for how each provider applies them. */
155
+ system?: string;
156
+ /** Images sent with the prompt. Providers without `capabilities.images` refuse the run. */
157
+ attachments?: Attachment[];
158
+ /** Ask for a JSON answer that matches this schema. The result carries it in `structured`. */
159
+ responseSchema?: ResponseSchema;
160
+ /** Upper bound on output tokens. API providers only. CLIs set their own limit. */
161
+ maxOutputTokens?: number;
123
162
  /** Continue an earlier conversation. Use the `sessionId` from a previous result. */
124
163
  sessionId?: string;
125
164
  signal?: AbortSignal;
@@ -261,4 +300,4 @@ declare function createFunnel(options?: FunnelOptions): {
261
300
  };
262
301
  type Funnel = ReturnType<typeof createFunnel>;
263
302
 
264
- export { type AuthStatus as A, type Capabilities as C, type EffortOption as E, type FunnelEvent as F, type Installation as I, type LoginEvent as L, type ModelInfo as M, type ProviderOverview as P, type RunInput as R, type Selection as S, type UpdateResult as U, type ProviderId as a, type ApprovalDecision as b, ACCESS_LEVELS as c, type AccessLevel as d, type ApiProviderId as e, type ApprovalRequest as f, type CliProviderId as g, type FinishReason as h, FunnelError as i, type LoginSession as j, type Provider as k, type RunResult as l, type Usage as m, type Funnel as n, type FunnelOptions as o, type ManifestModel as p, type ModelManifest as q, type RunStream as r, createFunnel as s, loadManifest as t };
303
+ export { type AuthStatus as A, type Capabilities as C, type EffortOption as E, type FunnelEvent as F, type ImageAttachment as I, type LoginEvent as L, type ModelInfo as M, type ProviderOverview as P, type RunInput as R, type Selection as S, type UpdateResult as U, type ProviderId as a, type ApprovalDecision as b, ACCESS_LEVELS as c, type AccessLevel as d, type ApiProviderId as e, type ApprovalRequest as f, type Attachment as g, type CliProviderId as h, type FinishReason as i, FunnelError as j, type Installation as k, type LoginSession as l, type Provider as m, type ResponseSchema as n, type RunResult as o, type Usage as p, type Funnel as q, type FunnelOptions as r, type ManifestModel as s, type ModelManifest as t, type RunStream as u, createFunnel as v, loadManifest as w };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { S as Selection, k as Provider, M as ModelInfo, a as ProviderId, e as ApiProviderId } from './funnel-CoOj5wr1.js';
2
- export { c as ACCESS_LEVELS, d as AccessLevel, b as ApprovalDecision, f as ApprovalRequest, A as AuthStatus, C as Capabilities, g as CliProviderId, E as EffortOption, h as FinishReason, n as Funnel, i as FunnelError, F as FunnelEvent, o as FunnelOptions, I as Installation, L as LoginEvent, j as LoginSession, p as ManifestModel, q as ModelManifest, P as ProviderOverview, R as RunInput, l as RunResult, r as RunStream, U as UpdateResult, m as Usage, s as createFunnel, t as loadManifest } from './funnel-CoOj5wr1.js';
1
+ import { S as Selection, m as Provider, M as ModelInfo, a as ProviderId, e as ApiProviderId } from './funnel-BZBIXenY.js';
2
+ export { c as ACCESS_LEVELS, d as AccessLevel, b as ApprovalDecision, f as ApprovalRequest, g as Attachment, A as AuthStatus, C as Capabilities, h as CliProviderId, E as EffortOption, i as FinishReason, q as Funnel, j as FunnelError, F as FunnelEvent, r as FunnelOptions, I as ImageAttachment, k as Installation, L as LoginEvent, l as LoginSession, s as ManifestModel, t as ModelManifest, P as ProviderOverview, n as ResponseSchema, R as RunInput, o as RunResult, u as RunStream, U as UpdateResult, p as Usage, v as createFunnel, w as loadManifest } from './funnel-BZBIXenY.js';
3
3
 
4
4
  /** Throws FunnelError("invalid-selection") when the selection asks for something the provider cannot do. */
5
5
  declare function validateSelection(selection: Selection, provider: Provider, models?: ModelInfo[]): void;
package/dist/index.js CHANGED
@@ -4,11 +4,11 @@ import {
4
4
  createFunnel,
5
5
  loadManifest,
6
6
  validateSelection
7
- } from "./chunk-2FZ64BNF.js";
7
+ } from "./chunk-CJLAAJHT.js";
8
8
  import {
9
9
  ACCESS_LEVELS,
10
10
  FunnelError
11
- } from "./chunk-YP5V5YEE.js";
11
+ } from "./chunk-KMG3CM2J.js";
12
12
  export {
13
13
  ACCESS_LEVELS,
14
14
  FunnelError,
@@ -1,4 +1,4 @@
1
- import { d as AccessLevel, n as Funnel } from '../funnel-CoOj5wr1.js';
1
+ import { d as AccessLevel, q as Funnel } from '../funnel-BZBIXenY.js';
2
2
  import { Server } from 'node:http';
3
3
 
4
4
  interface OpenAIDefaults {
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  createHandler,
3
3
  serveNode
4
- } from "../chunk-GF7JASHO.js";
5
- import "../chunk-YP5V5YEE.js";
4
+ } from "../chunk-TBSSPWZE.js";
5
+ import "../chunk-KMG3CM2J.js";
6
6
  export {
7
7
  createHandler,
8
8
  serveNode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-funnel",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Use Claude Code, Codex, Cursor Agent and Antigravity CLIs like an API, on your subscription.",
5
5
  "license": "MIT",
6
6
  "type": "module",