@xynogen/pix-9router 0.4.5 → 0.7.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/README.md CHANGED
@@ -1,58 +1,40 @@
1
1
  # pix-9router
2
2
 
3
- Pi coding agent extension — **9Router provider** + **fetch**, **search**, and **transcribe** tools backed by an internal OpenAI-compatible router API.
3
+ Pi extension with a 9Router model provider.
4
4
 
5
- ## What's included
5
+ Other 9Router features live in separate packages. Each one reuses
6
+ `NINEROUTER_URL` and `NINEROUTER_KEY`:
6
7
 
7
- | Module | Type | Description |
8
- |---|---|---|
9
- | `provider` | provider | Registers `9router` provider with live model list from the router API |
10
- | `fetch` | tool | `fetch(url, format, max_characters?)` — fetches web pages via exa through the router, falls back to curl |
11
- | `search` | tool | `search(query, search_type, max_results?)` — web/news search via exa through the router, falls back to curl |
12
- | `transcribe` | tool | `transcribe(file, model?, language?)` — speech-to-text via `/audio/transcriptions` endpoint (default: `dg/nova-3`), falls back to curl |
13
-
14
- ## Install
15
-
16
- ```bash
17
- pi install npm:@xynogen/pix-9router
18
- ```
19
-
20
- > Standalone/opt-in — **not** bundled by [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core). Needs a 9Router API key, so only useful if you route through 9Router.
8
+ - Speech tools (`transcribe`, `speak`, `/stt`) moved to `@xynogen/pix-voice` in
9
+ 0.7.0. Pick `9router` as the provider in its `/voice` command.
10
+ - Web search and fetch live in `@xynogen/pix-web`.
21
11
 
22
12
  ## Environment
23
13
 
24
- | Variable | Required | Default | Description |
25
- |---|---|---|---|
26
- | `ROUTER_API_KEY` | Yes | — | Bearer token for the router API |
27
- | `ROUTER_API_BASE` | No | `https://9router.example.com/v1` | Override router base URL |
28
-
29
- Add to your `~/.zsh_local` (or equivalent):
14
+ Use the upstream standard names:
30
15
 
31
16
  ```bash
32
- export ROUTER_API_KEY="your-key-here"
33
- # export ROUTER_API_BASE="https://your-router.example.com/v1" # optional
17
+ export NINEROUTER_URL="https://your-router.example.com"
18
+ export NINEROUTER_KEY="your-key-here"
34
19
  ```
35
20
 
36
- ## How it works
21
+ Legacy aliases remain supported:
37
22
 
38
- - **Provider**: on load, fetches `/models` from the router and registers them with Pi. The [modelgrep](https://modelgrep.com) catalog (via [`@xynogen/pix-data`](https://github.com/xynogen/pix-mono/tree/main/packages/pix-data)'s shared cache) is used internally to fill missing context window / modality fields where the router response omits them. Model list is cached at `~/.cache/pi/9router.json` (TTL 30 min).
39
- - **fetch / search**: POST to `/web/fetch` and `/search` on the router (which proxies to exa). If the router is unreachable, falls back to `curl` (for `fetch`, raw URL fetch; for `search`, the same `/search` endpoint via curl). Tool output is rendered dimmed so fetched web content reads like faded context rather than primary output.
40
- - **transcribe**: POST to `/audio/transcriptions` (Deepgram Nova 3 by default). Accepts any audio file path; falls back to curl. Pass the optional `output_file` to write the full transcription to disk (parent dirs created, path pre-flight checked) and return only a short path summary to the model — useful for long results; without it the text is returned inline (truncated to 50,000 chars).
41
-
42
- ## Compact results
43
-
44
- Terminal tool results collapse after the shared Pix delay into metadata-driven rows while preserving all model-visible content. Examples include `✓ fetch https://example.com · 12.4K chars · markdown`, `⚡ search “query” · 8 results · curl fallback`, and `✓ transcribe meeting.mp3 · 12.4K chars · dg/nova-3`. Failures use `✗`; cancelled or fallback outcomes use `⚡`. Running and partial updates remain live, and expanding an elapsed row restores the normal dimmed content preview without restarting its timer. Configure the behavior with `collapse.delaySec` and the `fetch`, `search`, or `transcribe` entries under `collapse.tools` in `~/.pi/agent/pix.json`.
45
-
46
- ## Full distro
23
+ ```text
24
+ ROUTER_API_BASE
25
+ ROUTER_API_KEY
26
+ ```
47
27
 
48
- Source: [github.com/xynogen/pix-mono](https://github.com/xynogen/pix-mono)
28
+ The standard names win when both sets exist.
49
29
 
50
- To install the complete pix suite (all packages + Pi itself):
30
+ ## Install
51
31
 
52
32
  ```bash
53
- curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
33
+ pi install npm:@xynogen/pix-9router
54
34
  ```
55
35
 
36
+ This package is standalone and opt-in. It is not bundled by `@xynogen/pix-core`.
37
+
56
38
  ## License
57
39
 
58
40
  MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xynogen/pix-9router",
3
- "version": "0.4.5",
4
- "description": "Pi extension — 9Router provider + fetch/search tools via router API",
3
+ "version": "0.7.0",
4
+ "description": "Pi extension — 9Router model provider",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
@@ -44,8 +44,7 @@
44
44
  "@earendil-works/pi-tui": "*"
45
45
  },
46
46
  "dependencies": {
47
- "@xynogen/pix-pretty": "^1.25.0",
48
- "typebox": "^1.1.38",
49
- "@xynogen/pix-runtime": "^0.8.0"
47
+ "@xynogen/pix-data": "^0.5.1",
48
+ "@xynogen/pix-runtime": "^0.10.2"
50
49
  }
51
50
  }
package/src/data.ts CHANGED
@@ -11,25 +11,26 @@
11
11
  * models.dev index helpers live there — imported here, not duplicated.
12
12
  *
13
13
  * Environment:
14
- * ROUTER_API_BASE — override base URL (default: https://9router.example.com/v1)
15
- * ROUTER_API_KEY — bearer token (required)
14
+ * NINEROUTER_URL — canonical base URL
15
+ * NINEROUTER_KEY — canonical bearer token
16
+ * ROUTER_API_BASE / ROUTER_API_KEY — legacy aliases
16
17
  */
17
18
 
18
19
  import { join } from "node:path";
19
- import { CACHE_DIR, DataSource } from "../../pix-data/src/index.ts";
20
+ import { CACHE_DIR, DataSource } from "@xynogen/pix-data";
20
21
 
21
22
  export type {
22
23
  ModelGrepModel,
23
24
  ModelsDevApi,
24
25
  ModelsDevModel,
25
- } from "../../pix-data/src/index.ts";
26
+ } from "@xynogen/pix-data";
26
27
  // Re-export the shared models.dev API so existing consumers (provider.ts)
27
28
  // can keep importing these names from "./data".
28
29
  export {
29
30
  buildModelsDevIndex,
30
31
  fetchModelsDevIndex,
31
32
  lookupInIndex,
32
- } from "../../pix-data/src/index.ts";
33
+ } from "@xynogen/pix-data";
33
34
 
34
35
  // ── Types ─────────────────────────────────────────────────────────────────────
35
36
 
@@ -58,7 +59,9 @@ interface RouterModelsResponse {
58
59
  const ROUTER_DEFAULT_BASE = "https://9router.example.com/v1";
59
60
 
60
61
  export function routerBaseUrl(): string {
61
- return (process.env.ROUTER_API_BASE || ROUTER_DEFAULT_BASE).replace(/\/$/, "");
62
+ const configured = process.env.NINEROUTER_URL || process.env.ROUTER_API_BASE;
63
+ const base = (configured || ROUTER_DEFAULT_BASE).replace(/\/$/, "");
64
+ return base.endsWith("/v1") ? base : `${base}/v1`;
62
65
  }
63
66
 
64
67
  // ── Router models ──────────────────────────────────────────────────────────────
@@ -67,10 +70,10 @@ export const routerModels = new DataSource<RouterModel[]>({
67
70
  label: "9router",
68
71
  url: () => `${routerBaseUrl()}/models`,
69
72
  headers: () => {
70
- const key = process.env.ROUTER_API_KEY;
73
+ const key = process.env.NINEROUTER_KEY || process.env.ROUTER_API_KEY;
71
74
  return key ? { Authorization: `Bearer ${key}` } : undefined;
72
75
  },
73
- skip: () => !process.env.ROUTER_API_KEY,
76
+ skip: () => !(process.env.NINEROUTER_KEY || process.env.ROUTER_API_KEY),
74
77
  cachePath: join(CACHE_DIR, "9router.json"),
75
78
  ttlMs: 30 * 60 * 1000, // 30 minutes
76
79
  parse: (raw) => ((raw as RouterModelsResponse).data ?? []).filter((m) => Boolean(m.id)),
package/src/index.ts CHANGED
@@ -1,26 +1,19 @@
1
1
  /**
2
- * pix-9router — Pi extension bundle
2
+ * pix-9router — Pi extension
3
3
  *
4
- * Registers:
5
- * - 9router provider (model list from self-hosted router API)
6
- * - fetch tool (web page fetch via exa through router)
7
- * - search tool (web/news search via exa through router)
8
- * - transcribe tool (speech-to-text via audio transcription API)
4
+ * Registers the 9router model provider (model list from the self-hosted router API).
5
+ * Speech tools moved to @xynogen/pix-voice. Web search and fetch live in
6
+ * @xynogen/pix-web. Both reuse the same 9Router environment variables.
9
7
  *
10
8
  * Environment:
11
- * ROUTER_API_BASE — override base URL (default: https://9router.example.com/v1)
12
- * ROUTER_API_KEY — bearer token (required for provider + tools)
9
+ * NINEROUTER_URL — canonical base URL
10
+ * NINEROUTER_KEY — canonical bearer token
11
+ * ROUTER_API_BASE / ROUTER_API_KEY — legacy aliases
13
12
  */
14
13
 
15
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
- import registerFetch from "./fetch.js";
17
15
  import registerProvider from "./provider.js";
18
- import registerSearch from "./search.js";
19
- import registerTranscribe from "./transcribe.js";
20
16
 
21
17
  export default async function (pi: ExtensionAPI): Promise<void> {
22
18
  await registerProvider(pi);
23
- registerFetch(pi);
24
- registerSearch(pi);
25
- registerTranscribe(pi);
26
19
  }
package/src/provider.ts CHANGED
@@ -97,14 +97,14 @@ export function getReasoning(model: RouterModel, devModel?: ModelsDevModel): boo
97
97
  }
98
98
 
99
99
  export default async function registerProvider(pi: ExtensionAPI): Promise<void> {
100
- const apiKey = process.env.ROUTER_API_KEY;
100
+ const apiKey = process.env.NINEROUTER_KEY || process.env.ROUTER_API_KEY;
101
101
 
102
102
  if (!apiKey) {
103
103
  // Register shell provider so the name is known; no models available yet.
104
104
  pi.registerProvider("9router", {
105
105
  name: "9Router",
106
106
  baseUrl: routerBaseUrl(),
107
- apiKey: "$ROUTER_API_KEY",
107
+ apiKey: "$NINEROUTER_KEY",
108
108
  api: "openai-completions",
109
109
  models: [],
110
110
  });
@@ -137,7 +137,7 @@ function providerConfig(
137
137
  return {
138
138
  name: "9Router",
139
139
  baseUrl: routerBaseUrl(),
140
- apiKey: "$ROUTER_API_KEY",
140
+ apiKey,
141
141
  api: "openai-completions",
142
142
  headers: { "User-Agent": "pi-coding-agent" },
143
143
  models: models.map(toModelConfig(devIndex)),
package/src/fetch.ts DELETED
@@ -1,260 +0,0 @@
1
- /**
2
- * fetch.ts — fetch tool via 9Router API, with curl fallback.
3
- *
4
- * Environment:
5
- * ROUTER_API_BASE — router API base URL (default: https://9router.example.com/v1)
6
- * ROUTER_API_KEY — bearer token for the router
7
- */
8
-
9
- import { StringEnum } from "@earendil-works/pi-ai";
10
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
- import { Type } from "typebox";
12
- import { apiPost, curl, isCancelled } from "./http.js";
13
- import { makeRenderCall, makeRenderResult } from "./render.js";
14
-
15
- type RouterOutcome = "running" | "fallback" | "success" | "cancelled" | "error";
16
- type FetchFormat = "markdown" | "text" | "html";
17
-
18
- export interface FetchResultDetails {
19
- _type: "fetchResult";
20
- outcome: RouterOutcome;
21
- url: string;
22
- format: FetchFormat;
23
- source?: "api" | "curl-fallback" | "failed";
24
- chars?: number;
25
- truncated?: boolean;
26
- }
27
-
28
- export interface FetchParams {
29
- url: string;
30
- format?: FetchFormat;
31
- max_characters?: number;
32
- }
33
-
34
- interface FetchResult {
35
- content: { type: "text"; text: string }[];
36
- details: FetchResultDetails;
37
- isError?: boolean;
38
- }
39
-
40
- interface FetchDependencies {
41
- apiPost: typeof apiPost;
42
- curl: typeof curl;
43
- }
44
-
45
- /**
46
- * Extract readable page content from the exa fetch JSON envelope.
47
- * Shape: { provider, url, title, content: { format, text }, ... }
48
- * Falls back to the raw string when it isn't the expected JSON shape.
49
- */
50
- export function formatFetchResult(raw: string): string {
51
- let parsed: unknown;
52
- try {
53
- parsed = JSON.parse(raw);
54
- } catch {
55
- return raw;
56
- }
57
- if (typeof parsed !== "object" || parsed === null) return raw;
58
-
59
- const obj = parsed as {
60
- title?: string | null;
61
- url?: string | null;
62
- content?: { text?: string | null } | string | null;
63
- };
64
-
65
- const text = typeof obj.content === "string" ? obj.content : (obj.content?.text ?? undefined);
66
- if (typeof text !== "string") return raw;
67
-
68
- const header: string[] = [];
69
- if (obj.title) header.push(`# ${obj.title}`);
70
- if (obj.url) header.push(`URL: ${obj.url}`);
71
-
72
- return header.length > 0 ? `${header.join("\n")}\n\n${text}` : text;
73
- }
74
-
75
- export async function executeFetch(
76
- params: FetchParams,
77
- signal?: AbortSignal,
78
- onUpdate?: (result: FetchResult) => void,
79
- dependencies: FetchDependencies = { apiPost, curl },
80
- ): Promise<FetchResult> {
81
- const maxChars = params.max_characters ?? 1000;
82
- const fmt = params.format ?? "markdown";
83
- let apiMsg = "";
84
-
85
- try {
86
- onUpdate?.({
87
- content: [{ type: "text", text: `Fetching (${fmt}): ${params.url}...` }],
88
- details: {
89
- _type: "fetchResult",
90
- outcome: "running",
91
- url: params.url,
92
- format: fmt,
93
- },
94
- });
95
-
96
- const raw = await dependencies.apiPost(
97
- "/web/fetch",
98
- {
99
- model: "exa",
100
- url: params.url,
101
- format: fmt,
102
- max_characters: maxChars,
103
- },
104
- signal,
105
- );
106
-
107
- const formatted = formatFetchResult(raw);
108
- const truncated =
109
- maxChars > 0 && formatted.length > maxChars
110
- ? `${formatted.slice(0, maxChars)}\n\n[truncated]`
111
- : formatted;
112
-
113
- return {
114
- content: [{ type: "text", text: truncated.slice(0, 20_000) }],
115
- details: {
116
- _type: "fetchResult",
117
- outcome: "success",
118
- url: params.url,
119
- format: fmt,
120
- source: "api",
121
- chars: formatted.length,
122
- truncated: maxChars > 0 && formatted.length > maxChars,
123
- },
124
- };
125
- } catch (apiErr: unknown) {
126
- apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
127
- if (isCancelled(apiErr, signal)) {
128
- return {
129
- content: [{ type: "text", text: `Fetch cancelled.\nAPI: ${apiMsg}` }],
130
- details: {
131
- _type: "fetchResult",
132
- outcome: "cancelled",
133
- url: params.url,
134
- format: fmt,
135
- },
136
- };
137
- }
138
- onUpdate?.({
139
- content: [
140
- {
141
- type: "text",
142
- text: `API failed: ${apiMsg}\nFalling back to curl...`,
143
- },
144
- ],
145
- details: {
146
- _type: "fetchResult",
147
- outcome: "running",
148
- url: params.url,
149
- format: fmt,
150
- },
151
- });
152
- }
153
-
154
- try {
155
- let html = await dependencies.curl(["-L", params.url]);
156
- const chars = html.length;
157
- const truncated = maxChars > 0 && html.length > maxChars;
158
- if (truncated) html = `${html.slice(0, maxChars)}\n\n[truncated]`;
159
-
160
- const banner =
161
- "[FALLBACK — raw curl] API unavailable, content may differ from exa formatted output.";
162
- const body =
163
- fmt === "html"
164
- ? `${banner}\n\n${html.slice(0, 19_500)}`
165
- : `${banner}\n\nRaw HTML (format='${fmt}' not applied):\n\n${html.slice(0, 19_000)}`;
166
-
167
- return {
168
- content: [{ type: "text", text: body.slice(0, 20_000) }],
169
- details: {
170
- _type: "fetchResult",
171
- outcome: "fallback",
172
- url: params.url,
173
- format: fmt,
174
- source: "curl-fallback",
175
- chars,
176
- truncated,
177
- },
178
- };
179
- } catch (curlErr: unknown) {
180
- const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
181
- return {
182
- content: [
183
- {
184
- type: "text",
185
- text: `Fetch failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
186
- },
187
- ],
188
- details: {
189
- _type: "fetchResult",
190
- outcome: "error",
191
- url: params.url,
192
- format: fmt,
193
- source: "failed",
194
- },
195
- isError: true,
196
- };
197
- }
198
- }
199
-
200
- export default function registerFetch(pi: ExtensionAPI): void {
201
- pi.registerTool({
202
- name: "fetch",
203
- label: "Fetch",
204
- renderShell: "self",
205
- description: "Fetch a web page as markdown, text, or raw HTML via exa. Returns page content.",
206
- promptSnippet:
207
- "fetch(url, format, max_characters?) — format: 'markdown', 'text', or 'html'. Read page content via exa.",
208
- promptGuidelines: [
209
- "fetch: format='markdown' for readable content, 'text' for plain extraction, 'html' for raw source. max_characters caps size (default 1000, 0 = unlimited; 5000-10000 for typical pages).",
210
- ],
211
- renderCall: makeRenderCall<unknown>("fetch", (args) =>
212
- String((args as Partial<FetchParams>).url ?? ""),
213
- ),
214
- renderResult: (
215
- (renderTerminal) => (result, options, theme, context) =>
216
- renderTerminal(result, options, theme, {
217
- ...context,
218
- isError: context.isError && options.expanded,
219
- })
220
- )(
221
- makeRenderResult<FetchResultDetails>({
222
- tool: "fetch",
223
- target: (details) => details.url,
224
- meta: (details) => {
225
- if (details.outcome === "error") return "failed";
226
- if (details.outcome === "cancelled") return "cancelled";
227
- const chars = `${details.chars ?? 0} chars`;
228
- return details.outcome === "fallback"
229
- ? `${chars} · curl fallback`
230
- : `${chars} · ${details.format}`;
231
- },
232
- status: (details) =>
233
- details.outcome === "error"
234
- ? "error"
235
- : details.outcome === "fallback" || details.outcome === "cancelled"
236
- ? "warning"
237
- : "success",
238
- }),
239
- ),
240
- parameters: Type.Object({
241
- url: Type.String({ description: "URL to fetch" }),
242
- format: Type.Optional(
243
- StringEnum(["markdown", "text", "html"] as const, {
244
- description:
245
- 'Optional choice. Enter exactly "markdown" for readable Markdown, "text" for plain text, or "html" for raw HTML. Default is "markdown".',
246
- }),
247
- ),
248
- max_characters: Type.Optional(
249
- Type.Number({
250
- description: "Max characters (default 1000, 0 = unlimited)",
251
- default: 1000,
252
- }),
253
- ),
254
- }),
255
-
256
- async execute(_toolCallId, params, signal, onUpdate) {
257
- return executeFetch(params as FetchParams, signal, onUpdate);
258
- },
259
- });
260
- }
package/src/http.ts DELETED
@@ -1,136 +0,0 @@
1
- /**
2
- * http.ts — shared 9Router HTTP plumbing.
3
- *
4
- * auth / apiPost / curl / isCancelled were duplicated across fetch.ts,
5
- * search.ts and transcribe.ts. Extracted here; the only real differences were
6
- * timeout values (audio needs longer) and curl stdin (search pipes a body),
7
- * so both are parameters with the old per-file defaults.
8
- */
9
-
10
- import { readFileSync } from "node:fs";
11
- import { basename } from "node:path";
12
- import { ioTimeoutMs, ioTimeoutSignal } from "@xynogen/pix-runtime/io";
13
- import { routerBaseUrl } from "./data.js";
14
-
15
- export function auth(): string | undefined {
16
- return process.env.ROUTER_API_KEY;
17
- }
18
-
19
- /** POST a JSON body to a router path, returning the raw response text. */
20
- export async function apiPost(
21
- path: string,
22
- body: Record<string, unknown>,
23
- signal?: AbortSignal,
24
- timeoutMs: number = ioTimeoutMs(),
25
- ): Promise<string> {
26
- const url = `${routerBaseUrl()}${path}`;
27
- const key = auth();
28
- const timeoutSignal =
29
- timeoutMs === ioTimeoutMs() ? ioTimeoutSignal() : AbortSignal.timeout(timeoutMs);
30
- const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
31
- const res = await fetch(url, {
32
- method: "POST",
33
- headers: {
34
- "Content-Type": "application/json",
35
- ...(key ? { Authorization: `Bearer ${key}` } : {}),
36
- },
37
- body: JSON.stringify(body),
38
- signal: requestSignal,
39
- });
40
- if (!res.ok) {
41
- const errText = await res.text().catch(() => "");
42
- throw new Error(`API ${res.status}: ${errText.slice(0, 500)}`);
43
- }
44
- return res.text();
45
- }
46
-
47
- export interface CurlOptions {
48
- /** Request timeout in ms (default ioTimeoutMs()). */
49
- timeoutMs?: number;
50
- /** Optional request body (was piped to curl stdin). */
51
- stdin?: string;
52
- }
53
-
54
- // ponytail: this is NOT a general curl shim — it parses only the flag forms the
55
- // three fallback call sites actually pass (-L, -X, -H, -d, -F key=value /
56
- // file=@path, plus the -sS/--connect-timeout/--max-time defaults added below).
57
- // New curl flags won't work here; extend the parser or call fetch directly.
58
- function curlArgsToRequest(args: string[]): { url: string; init: RequestInit } {
59
- const headers = new Headers();
60
- let method = "GET";
61
- let body: string | undefined;
62
- let form: FormData | undefined;
63
- let url = "";
64
- for (let i = 0; i < args.length; i++) {
65
- const a = args[i] ?? "";
66
- switch (a) {
67
- case "-sS":
68
- case "-L":
69
- break; // silent / follow-redirects are fetch defaults
70
- case "--connect-timeout":
71
- case "--max-time":
72
- i++; // value handled by AbortSignal below
73
- break;
74
- case "-X":
75
- method = args[++i] ?? method;
76
- break;
77
- case "-H": {
78
- const h = args[++i] ?? "";
79
- const idx = h.indexOf(":");
80
- if (idx >= 0) headers.set(h.slice(0, idx).trim(), h.slice(idx + 1).trim());
81
- break;
82
- }
83
- case "-d":
84
- body = args[++i] ?? "";
85
- break;
86
- case "-F": {
87
- form ??= new FormData();
88
- const field = args[++i] ?? "";
89
- const eq = field.indexOf("=");
90
- const key = field.slice(0, eq);
91
- const val = field.slice(eq + 1);
92
- if (val.startsWith("@")) {
93
- const path = val.slice(1);
94
- form.set(key, new Blob([readFileSync(path)]), basename(path));
95
- } else {
96
- form.set(key, val);
97
- }
98
- break;
99
- }
100
- default:
101
- if (!a.startsWith("-")) url = a; // positional = URL
102
- }
103
- }
104
- const init: RequestInit = { method };
105
- if (form)
106
- init.body = form; // let fetch set the multipart boundary
107
- else if (body !== undefined) init.body = body;
108
- if ([...headers.keys()].length > 0) init.headers = headers;
109
- return { url, init };
110
- }
111
-
112
- /** Raw fetch fallback (curl-signature compatible), rejecting on non-2xx or timeout. */
113
- export async function curl(args: string[], opts: CurlOptions = {}): Promise<string> {
114
- const timeoutMs = opts.timeoutMs ?? ioTimeoutMs();
115
- const { url, init } = curlArgsToRequest(args);
116
- if (opts.stdin !== undefined) init.body = opts.stdin;
117
- init.signal = AbortSignal.timeout(timeoutMs);
118
- let res: Response;
119
- try {
120
- res = await fetch(url, init);
121
- } catch (err) {
122
- if (err instanceof DOMException && err.name === "TimeoutError") {
123
- throw new Error("curl timed out");
124
- }
125
- throw new Error(`curl failed: ${err instanceof Error ? err.message : String(err)}`);
126
- }
127
- if (!res.ok) {
128
- const errText = await res.text().catch(() => "");
129
- throw new Error(`curl exit ${res.status}: ${errText.slice(0, 300)}`);
130
- }
131
- return res.text();
132
- }
133
-
134
- export function isCancelled(error: unknown, signal: AbortSignal | undefined): boolean {
135
- return signal?.aborted === true || (error instanceof DOMException && error.name === "AbortError");
136
- }