@sandgarden/miranda 0.1.90 → 0.1.95

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/collector/stub.sh CHANGED
@@ -44,15 +44,15 @@
44
44
  # now downloads the same release from "sandgardenhq/plugins" with the
45
45
  # "miranda-collector" prefix (#813). They stay placeholders rather than
46
46
  # literals so the release host can move without editing this file.
47
- BUILD_VERSION="898a3aeb70f0"
48
- RELEASE_TAG="collector-898a3aeb70f0"
47
+ BUILD_VERSION="5d75af2e5f49"
48
+ RELEASE_TAG="collector-5d75af2e5f49"
49
49
  RELEASE_REPO="sandgardenhq/plugins"
50
50
  ASSET_PREFIX="miranda-collector"
51
- CHECKSUM_DARWIN_ARM64="634e7fcfcdaf31dbc634c05c376cf7cb06faf39cbaed6dd97cc294795419433f"
52
- CHECKSUM_DARWIN_X64="bf72e11cdd05607323e9519c255ad934c8b50ab9c7893f3938832a1dd5187d11"
53
- CHECKSUM_LINUX_X64="adcdb67e25f025a604582e8346728692995121bbef5325db7dc227c6bbebc111"
54
- CHECKSUM_LINUX_ARM64="ab06c59c86067b622374f725f45e87c3cb68287f55534a8fd2dd6ff5c32871e1"
55
- CHECKSUM_WINDOWS_X64="826194a0fc0ab7af0c32b1405e9ffb82603e66033c26fe1e45dc2f61e8786d5f"
51
+ CHECKSUM_DARWIN_ARM64="11886dceb745338298af299efa8e7fae2dbc1e5d68a40f506754d454768ac73b"
52
+ CHECKSUM_DARWIN_X64="72c7e9bc3835cd820dc6340b4834ed1d05716051309466eea6b92b07d031ea24"
53
+ CHECKSUM_LINUX_X64="45e907aa91af07d27a0a3dedba3470cd2e63b5a831ca6d64dfeb117d5dccd248"
54
+ CHECKSUM_LINUX_ARM64="0f5308e597e2b0e3dcf50a57dc49f64c3b74ff620a39812227b7a8d1b755b666"
55
+ CHECKSUM_WINDOWS_X64="70cd1ac6287e5b5729cfabd0e829f635221abd59d3a5241b4a7f127f674c59f5"
56
56
 
57
57
  # A download lock older than this is a downloader that died mid-run: take it
58
58
  # over (mirrors the collector's sweep-lock staleness cutoff).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sandgarden/miranda",
3
- "version": "0.1.90",
3
+ "version": "0.1.95",
4
4
  "type": "module",
5
5
  "description": "Miranda OpenCode plugin — token-usage tracking skill + the gloria.dev MCP server's scoped /miranda tools.",
6
6
  "main": "index.js",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: setting-up-usage-tracking
3
- description: Use when installing the Miranda plugin into a repo ("set up token tracking", "enable usage tracking", "install miranda"), when a repo is missing .miranda/USING-MIRANDA.md or the miranda section in CLAUDE.md/AGENTS.md, or after a Miranda plugin update to refresh a stale doc — copies the canonical USING-MIRANDA.md agent doc into the repo, wires an evergreen Miranda section into the agent instruction files (with the user's permission), enrols this machine for usage collection, and registers the current repository as a Miranda project if it isn't one. Idempotent; safe to re-run.
3
+ description: Use when installing the Miranda plugin into a repo ("set up token tracking", "enable usage tracking", "install miranda"), when a repo is missing .miranda/USING-MIRANDA.md or the miranda section in CLAUDE.md/AGENTS.md, or after a Miranda plugin update to refresh a stale doc — copies the canonical USING-MIRANDA.md agent doc into the repo, wires an evergreen Miranda section into the agent instruction files (with the user's permission), enrols this machine for usage collection, writes Miranda's identifying headers into the config of any coding agent routed through an AI gateway, and registers the current repository as a Miranda project if it isn't one. Idempotent; safe to re-run.
4
4
  ---
5
5
 
6
6
  # Setting Up Usage Tracking
@@ -25,9 +25,15 @@ repository is a Miranda project — the collector attributes a session to a
25
25
  project only when the session's `origin` remote matches one of a project's repositories, so an
26
26
  unregistered repository's spend all lands in **Unassigned**.
27
27
 
28
+ If a coding agent here sends its requests through an AI gateway (LiteLLM,
29
+ Cloudflare AI Gateway, Bifrost, or an OTLP exporter), Miranda can also import
30
+ its usage from the gateway. For that, each request has to say which machine
31
+ and project it came from, so the skill writes those two values (the
32
+ **carrier**) into that agent's own config (step 6).
33
+
28
34
  Never modify the user's files without showing them exactly what will change
29
35
  and getting a yes first. Registering the repository writes no file, and is
30
- done without asking (step 6).
36
+ done without asking (step 7).
31
37
 
32
38
  ## What the collector reports
33
39
 
@@ -126,9 +132,32 @@ commitment the team has made, not a suggestion.
126
132
  → the deprecated `GLORIA_HOME` → `$XDG_CONFIG_HOME/sandgarden` →
127
133
  `~/.config/sandgarden`). `daemon status` always exits 0, so "not
128
134
  enrolled" is not an error. Treat this as extra detail for the report in
129
- step 7, not as the enrolment test — the `config.json` check above is the
135
+ step 8, not as the enrolment test — the `config.json` check above is the
130
136
  one that has to work on every machine.
131
137
 
138
+ - **Is any agent routed through an AI gateway?** Read only; change
139
+ nothing. Look for a base URL each agent is pointed at:
140
+ - Claude Code: `ANTHROPIC_BASE_URL` in the shell environment, or in the
141
+ `env` block of `~/.claude/settings.json`, `.claude/settings.json` or
142
+ `.claude/settings.local.json`.
143
+ - Codex: the top-level `model_provider` in `$CODEX_HOME/config.toml`
144
+ (default `~/.codex/config.toml`), naming a custom
145
+ `[model_providers.<id>]` block with a `base_url`.
146
+ - OpenCode: a provider with `options.baseURL` in the repo's
147
+ `opencode.json` or in `~/.config/opencode/opencode.json`.
148
+
149
+ For each base URL found, ask the script which gateway it is:
150
+
151
+ ```sh
152
+ node <skill-dir>/scripts/write-gateway-carriers.mjs gateway-for <base-url>
153
+ ```
154
+
155
+ `<skill-dir>` is this skill's base directory. It prints
156
+ `{ "gateway": … }`: `cloudflare-ai-gateway` or `openrouter` when the host
157
+ says so, otherwise `null`, and then you ask (step 2). An agent with no
158
+ base URL talks to its provider directly and needs nothing here. Never
159
+ print an API key or token you see while reading these files.
160
+
132
161
  ### 2. Propose and ask permission — once
133
162
 
134
163
  Tell the user precisely what will happen, in one message, and ask a single
@@ -140,10 +169,17 @@ yes/no. Cover only the actions actually needed, e.g.:
140
169
  - create `AGENTS.md` containing the section, when neither instruction file
141
170
  exists;
142
171
  - enrol this machine for usage tracking, if it is not already, saying what
143
- the collector reports (see "What the collector reports").
144
-
145
- If everything is already current, say so and skip to step 6.
146
- Registering the repository is not part of this yes/no — step 6 does it
172
+ the collector reports (see "What the collector reports");
173
+ - for each gateway-routed agent from step 1, write Miranda's carrier into
174
+ that agent's config file (step 6 names the file per agent). When the
175
+ gateway wasn't inferred, ask which one it is in the same message:
176
+ LiteLLM (`litellm`), Cloudflare AI Gateway (`cloudflare-ai-gateway`),
177
+ Bifrost (`bifrost`), an OTLP exporter (`otlp`), or OpenRouter
178
+ (`openrouter`). Miranda imports from no other gateway yet, so for any other
179
+ answer say so and leave that agent alone.
180
+
181
+ If everything is already current, say so and skip to step 7.
182
+ Registering the repository is not part of this yes/no — step 7 does it
147
183
  without asking. If the user
148
184
  declines the file edits, stop — do not partially apply.
149
185
 
@@ -199,8 +235,22 @@ the same `config.json` — never do two of them.
199
235
 
200
236
  #### Fallback: minting the credential through MCP
201
237
 
202
- 1. Call the `miranda` MCP tool **`enable_usage_tracking`**, always passing a
203
- `machineLabel` — never omit it. Clerk key names must be unique per org,
238
+ 1. Read this machine's id first. It is the collector's own enrolment id, and
239
+ the command creates it, in the collector's format, if the machine has
240
+ none yet:
241
+
242
+ ```sh
243
+ node <skill-dir>/scripts/write-gateway-carriers.mjs machine-id
244
+ ```
245
+
246
+ It prints `{ "machineId": …, "source": …, "file": … }`. The id is not a
247
+ secret.
248
+
249
+ Call the `miranda` MCP tool **`enable_usage_tracking`**, passing that
250
+ `machineId` and always passing a `machineLabel` — never omit either.
251
+ `machineId` links this machine to you on the server, which is what lets
252
+ a gateway request carrying it count as yours even before the collector
253
+ reports anything. Clerk key names must be unique per org,
204
254
  and omitting `machineLabel` falls back to a non-unique default name, so a
205
255
  second machine (or a re-run after a partial failure) collides with an
206
256
  already-minted key and the call fails with a Clerk `409 token_creation_conflict`.
@@ -210,6 +260,7 @@ the same `config.json` — never do two of them.
210
260
  one, keep it unique the same way (append a short suffix) rather than
211
261
  passing their label bare. It mints a write-only, org-scoped Clerk API key
212
262
  and returns `{ apiBaseUrl, ingestToken }` plus write instructions.
263
+
213
264
  2. Write `$XDG_CONFIG_HOME/sandgarden/config.json` — `~/.config/sandgarden/config.json`
214
265
  when `XDG_CONFIG_HOME` is unset — directly from the tool result, merging
215
266
  with any existing keys in the file. The directory is per-machine, not
@@ -279,9 +330,69 @@ this machine's local session stores whether or not any agent plugin fires, so
279
330
  no further wiring is needed. See `USING-MIRANDA.md`'s "Codex, OpenCode, and
280
331
  Cursor" section for per-agent notes.
281
332
 
282
- ### 6. Register this repository as a project
333
+ ### 6. Write the gateway carriers
334
+
335
+ Only for the agents step 1 found routed through a gateway, and only after
336
+ the user said yes in step 2. For each one, run:
337
+
338
+ ```sh
339
+ node <skill-dir>/scripts/write-gateway-carriers.mjs write --agent <claude-code|codex|opencode> --gateway <gateway> --repo <repo-root>
340
+ ```
283
341
 
284
- Run this after step 5, whichever enrolment path ran — and also when steps 2–5
342
+ Add `--provider <id>` for Codex or OpenCode when step 1 found the provider
343
+ id and the script would otherwise have to guess.
344
+
345
+ The script uses this machine's id (creating it as the `machine-id` command
346
+ does) and the repository's `origin` URL, normalized, and writes the carrier
347
+ the gateway records:
348
+
349
+ | Gateway | What the agent will send |
350
+ | ----------------------- | ------------------------------------------------------------------------------------ |
351
+ | `litellm` | Headers `Miranda-Machine` and `Miranda-Project` |
352
+ | `cloudflare-ai-gateway` | One `cf-aig-metadata` header: `miranda_machine`, `miranda_project`, `miranda_agent` |
353
+ | `bifrost` | Headers `x-bf-lh-miranda-machine` and `x-bf-lh-miranda-project` |
354
+ | `otlp` | `OTEL_RESOURCE_ATTRIBUTES`: `miranda.machine.id`, `miranda.project`, `miranda.agent` |
355
+ | `openrouter` | Nothing: OpenRouter has no carrier |
356
+
357
+ into one place per agent, merged with any headers the agent already sends:
358
+
359
+ - **Claude Code:** `env.ANTHROPIC_CUSTOM_HEADERS` in the repo's
360
+ `.claude/settings.local.json` (per user, per project, not committed).
361
+ Never the committed `.claude/settings.json`. Tell the user it needs Claude
362
+ Code v2.1.227 or later (`claude --version`), and takes effect once they
363
+ trust the folder. For `otlp` the script writes `OTEL_RESOURCE_ATTRIBUTES`
364
+ in the same `env` block.
365
+ - **Codex:** `http_headers` on the active custom `[model_providers.<id>]`
366
+ block in `$CODEX_HOME/config.toml`. Codex reads providers only from that
367
+ user-level file, so tell the user the project value now applies to every
368
+ repository they run Codex in, until the skill is re-run in another one.
369
+ - **OpenCode:** `options.headers` on the gateway provider in the repo's
370
+ `opencode.json`. OpenCode merges it with the provider defined globally.
371
+ If `git ls-files --error-unmatch opencode.json` succeeds the file is
372
+ committed: tell the user the change holds their personal machine id and
373
+ should stay out of the commit.
374
+
375
+ On success it prints `{ "status": "written" | "unchanged", "file": … }`.
376
+ A re-run with the same values is `unchanged`. A run naming a different
377
+ gateway replaces the old carrier rather than adding a second one.
378
+
379
+ Exit code 2 is a refusal, and nothing was written. Relay its stderr to the
380
+ user as it is: it says what to fix. The refusals are OpenRouter (it has no
381
+ carrier, so recommend running the collector for that agent), a Codex
382
+ built-in provider (`openai`, `ollama`, `lmstudio` can't carry headers), a
383
+ `cf-aig-metadata` that would pass Cloudflare's five-entry limit, `otlp` for
384
+ Codex or OpenCode (their config has no place for it, so the user sets
385
+ `OTEL_RESOURCE_ATTRIBUTES` in the exporter's environment), and a config file
386
+ the script can't safely rewrite. Exit code 1 means you passed bad arguments.
387
+
388
+ The script only adds Miranda's values. It never sets `ANTHROPIC_BASE_URL`,
389
+ a `base_url` or `baseURL`, or any credential: pointing the agent at the
390
+ gateway is the user's job. The work item still comes from
391
+ `tag_session_work_item`, as `USING-MIRANDA.md` says.
392
+
393
+ ### 7. Register this repository as a project
394
+
395
+ Run this after step 6, whichever enrolment path ran — and also when steps 2–6
285
396
  had nothing to do. Do it **without asking**: the developer is setting up
286
397
  tracking for exactly this repository, and registering it is the only way its
287
398
  spend stops landing in Unassigned. Use the `miranda` MCP tools only.
@@ -298,7 +409,7 @@ spend stops landing in Unassigned. Use the `miranda` MCP tools only.
298
409
  transcript, a tool argument or the report. Use `origin` and nothing else —
299
410
  it is the only remote the collector reads. If this isn't a git repository,
300
411
  or the command prints nothing on stdout (no `origin` remote — git's
301
- `error: No such remote` goes to stderr), skip the rest of step 6 and say
412
+ `error: No such remote` goes to stderr), skip the rest of step 7 and say
302
413
  why in the report (e.g. "this repository has no `origin` remote, so there
303
414
  is no URL a project could match").
304
415
 
@@ -364,7 +475,7 @@ spend stops landing in Unassigned. Use the `miranda` MCP tools only.
364
475
  repositories are one product, and attaching moves spend between projects.
365
476
  Never call `attach_project_repository` yourself.
366
477
 
367
- 5. **Report what happened** in step 7, from the result's `project` and
478
+ 5. **Report what happened** in step 8, from the result's `project` and
368
479
  `backfill`:
369
480
  - the `slug` it registered under (and the name, when it isn't the same as
370
481
  the slug);
@@ -400,24 +511,29 @@ dropped connection, a timeout), retry it once. If it still fails, or the
400
511
  `miranda` MCP server isn't connected, or the call returns an error — say so,
401
512
  with the error, and carry on to the report. It doesn't undo the file edits or the enrolment.
402
513
 
403
- ### 7. Report
514
+ ### 8. Report
404
515
 
405
516
  Summarize what changed (files created/updated, section inserted where,
406
- usage tracking configured or already present, the project registered or
407
- matched — or why the step was skipped — with what step 6 moved) and suggest
408
- committing the changes, e.g. `chore: wire miranda agent doc into instruction
409
- files`.
517
+ usage tracking configured or already present, each gateway carrier written,
518
+ unchanged or refused and why, the project registered or matched — or why
519
+ the step was skipped — with what step 7 moved) and suggest committing the
520
+ changes, e.g. `chore: wire miranda agent doc into instruction files`. Leave
521
+ `.claude/settings.local.json`, the Codex config and any `opencode.json`
522
+ carrier change out of that commit: they hold this machine's id.
410
523
 
411
524
  ## Idempotency rules
412
525
 
413
526
  - Re-running with identical content is a no-op; say "already current".
414
527
  - Re-running in a repository that is already a project registers nothing:
415
- step 6 finds the match and names it.
528
+ step 7 finds the match and names it.
529
+ - Re-running the carrier write with the same gateway is a no-op
530
+ (`unchanged`); with a different gateway it replaces the carrier.
416
531
  - Marker blocks are always replaced in place, never duplicated. If a file
417
532
  somehow contains multiple marker blocks, replace the first and remove the
418
533
  rest, and mention it.
419
534
  - Never touch anything outside the marker block in an instruction file, and
420
- never edit any other file.
535
+ never edit any other file — except the agent config files step 6 writes,
536
+ and only through the script.
421
537
  - Never re-mint a usage-tracking credential when step 1 found a `config.json`
422
538
  in either location (including the pre-rename `~/.gloria/config.json`). Offer
423
539
  to add `machineLabel` or rotate only if the user asks.
@@ -0,0 +1,776 @@
1
+ #!/usr/bin/env node
2
+ // Writes Miranda's gateway carriers into a coding agent's own config (#1889;
3
+ // design §5 of docs/plans/2026-10-27-ai-gateway-import-design.md). Run by the
4
+ // setting-up-usage-tracking skill. Dependency-free: it ships inside the
5
+ // published plugin and runs on the developer's machine with plain `node`.
6
+ //
7
+ // It only ever adds Miranda's identifying values. It never writes a base URL
8
+ // or a credential: pointing the agent at the gateway is the customer's job.
9
+
10
+ import { execFileSync } from "node:child_process"
11
+ import { randomUUID } from "node:crypto"
12
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
13
+ import { homedir as osHomedir } from "node:os"
14
+ import { dirname, join } from "node:path"
15
+ import { fileURLToPath } from "node:url"
16
+ import { parseArgs } from "node:util"
17
+
18
+ /** The gateways a carrier can be written for: core's `GatewayType` ids. */
19
+ export const GATEWAYS = ["litellm", "cloudflare-ai-gateway", "bifrost", "otlp", "openrouter"]
20
+
21
+ /** Header names per header-logging gateway: [machine, project]. */
22
+ const HEADER_CARRIERS = {
23
+ litellm: ["Miranda-Machine", "Miranda-Project"],
24
+ bifrost: ["x-bf-lh-miranda-machine", "x-bf-lh-miranda-project"],
25
+ }
26
+
27
+ /** Cloudflare logs no request headers, only `cf-aig-metadata`'s JSON. */
28
+ const CF_METADATA_HEADER = "cf-aig-metadata"
29
+ const CF_MACHINE_KEY = "miranda_machine"
30
+ const CF_PROJECT_KEY = "miranda_project"
31
+ const CF_AGENT_KEY = "miranda_agent"
32
+ const CF_MAX_ENTRIES = 5
33
+
34
+ /** OTLP resource attributes, set through `OTEL_RESOURCE_ATTRIBUTES`. */
35
+ const OTEL_MACHINE_KEY = "miranda.machine.id"
36
+ const OTEL_PROJECT_KEY = "miranda.project"
37
+ const OTEL_AGENT_KEY = "miranda.agent"
38
+
39
+ /** Every header name a gateway carrier owns, lowercased. Removed before a
40
+ * write, so a re-run, a changed value, or a switch to another gateway never
41
+ * leaves two carriers behind. */
42
+ const MIRANDA_HEADER_NAMES = new Set(
43
+ Object.values(HEADER_CARRIERS)
44
+ .flat()
45
+ .map((name) => name.toLowerCase()),
46
+ )
47
+ const MIRANDA_CF_KEYS = new Set([CF_MACHINE_KEY, CF_PROJECT_KEY, CF_AGENT_KEY])
48
+ const MIRANDA_OTEL_KEYS = new Set([OTEL_MACHINE_KEY, OTEL_PROJECT_KEY, OTEL_AGENT_KEY])
49
+
50
+ /** A refusal the skill relays to the user verbatim. Nothing was written. */
51
+ function refuse(message) {
52
+ const error = new Error(message)
53
+ error.name = "CarrierRefusal"
54
+ return error
55
+ }
56
+
57
+ /** How `gateway` carries the Miranda values (design §5). */
58
+ function carrierKind(gateway) {
59
+ if (gateway === "openrouter") {
60
+ throw refuse(
61
+ "OpenRouter has no Miranda carrier: it doesn't pass custom request headers through to " +
62
+ "the records Miranda imports, so nothing written here would reach Miranda. Run the " +
63
+ "Miranda collector on this machine instead; its copy of the session is the one counted.",
64
+ )
65
+ }
66
+ if (gateway in HEADER_CARRIERS) return "headers"
67
+ if (gateway === "cloudflare-ai-gateway") return "cf-aig-metadata"
68
+ if (gateway === "otlp") return "otel"
69
+ throw refuse(`Unknown gateway "${gateway}". Use one of: ${GATEWAYS.join(", ")}.`)
70
+ }
71
+
72
+ // --- values -----------------------------------------------------------------
73
+
74
+ /** The machine id's only valid form: the collector's lowercase UUID. The
75
+ * server ignores anything else as no signal (design §5). */
76
+ const MACHINE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
77
+
78
+ /** The server's cap on a project carrier value (design §5). */
79
+ const MAX_PROJECT_LENGTH = 512
80
+
81
+ /** Refuse values the server would discard, before anything is written. */
82
+ export function validateCarrierValues({ machineId, project }) {
83
+ if (typeof machineId !== "string" || !MACHINE_ID_PATTERN.test(machineId)) {
84
+ throw refuse(
85
+ `"${machineId}" isn't a valid machine id: Miranda only accepts the collector's lowercase UUID.`,
86
+ )
87
+ }
88
+ if (typeof project !== "string" || project.trim() === "") {
89
+ throw refuse("The project is empty: run this inside a repository with an origin remote.")
90
+ }
91
+ if (project.length > MAX_PROJECT_LENGTH) {
92
+ throw refuse(
93
+ `The project URL is longer than ${MAX_PROJECT_LENGTH} characters, which Miranda ignores.`,
94
+ )
95
+ }
96
+ }
97
+
98
+ /** GitHub `owner/repo` shorthand. */
99
+ const GITHUB_SHORTHAND = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/
100
+ /** scp-like SSH remotes, `git@github.com:owner/repo.git`. */
101
+ const SCP_LIKE_SSH = /^[\w.-]+@([\w.-]+):(.+)$/
102
+
103
+ function stripGitSuffixAndSlash(path) {
104
+ return path.replace(/\.git\/*$/i, "").replace(/\/+$/, "")
105
+ }
106
+
107
+ /**
108
+ * A port of core's `normalizeRepoUrl` (packages/core/src/inventory-schema.ts),
109
+ * which the server resolves a project with; a parity test pins the two
110
+ * together. `URL.host` drops any userinfo, so a credential in the remote
111
+ * never reaches a config file.
112
+ */
113
+ export function normalizeRepoUrl(input) {
114
+ const trimmed = input.trim()
115
+ if (GITHUB_SHORTHAND.test(trimmed)) return `https://github.com/${trimmed}`
116
+ const scp = SCP_LIKE_SSH.exec(trimmed)
117
+ if (scp !== null) return `https://${scp[1]}/${stripGitSuffixAndSlash(scp[2])}`
118
+ try {
119
+ const url = new URL(trimmed)
120
+ if (url.protocol === "ssh:" || url.protocol === "git:" || url.protocol === "git+ssh:") {
121
+ return `https://${url.host}${stripGitSuffixAndSlash(url.pathname)}`
122
+ }
123
+ if (url.protocol === "http:" || url.protocol === "https:") {
124
+ return `${url.protocol}//${url.host}${stripGitSuffixAndSlash(url.pathname)}`
125
+ }
126
+ return trimmed
127
+ } catch {
128
+ return trimmed
129
+ }
130
+ }
131
+
132
+ /** The gateway a base URL points at, when its host says so; else null and
133
+ * the skill asks. Self-hosted gateways (LiteLLM, Bifrost) have no fixed
134
+ * host, so only the hosted ones are inferred. */
135
+ export function inferGateway(baseUrl) {
136
+ let host
137
+ try {
138
+ host = new URL(baseUrl).hostname.toLowerCase()
139
+ } catch {
140
+ return null
141
+ }
142
+ if (host === "gateway.ai.cloudflare.com") return "cloudflare-ai-gateway"
143
+ if (host === "openrouter.ai" || host.endsWith(".openrouter.ai")) return "openrouter"
144
+ return null
145
+ }
146
+
147
+ // --- machine id -------------------------------------------------------------
148
+ //
149
+ // The machine value is the collector's own enrolment id, so the server
150
+ // resolves a gateway request's user through the same `machine_owner` row the
151
+ // collector's ingest uses. This mirrors packages/collector/src/state.ts; keep
152
+ // the two in step.
153
+
154
+ /** Full-path overrides of the collector's home, highest precedence first. */
155
+ const HOME_OVERRIDE_VARS = ["SANDGARDEN_HOME", "GLORIA_HOME"]
156
+
157
+ /** The entries whose presence tells the collector its one-time `~/.gloria`
158
+ * migration is done (MIGRATED_STATE_ENTRIES in state.ts). */
159
+ const MIGRATION_GATE_ENTRIES = [
160
+ "config.json",
161
+ "machine.json",
162
+ "usage-cursors.json",
163
+ "usage-queue",
164
+ "work-item-throttle.json",
165
+ "sync-debounce.json",
166
+ "heartbeat.json",
167
+ "declared-work-item.json",
168
+ ]
169
+
170
+ function envPath(env, homedir, name) {
171
+ const raw = env[name]
172
+ if (raw === undefined || raw === "") return null
173
+ return raw.startsWith("~/") ? join(homedir, raw.slice(2)) : raw
174
+ }
175
+
176
+ function homeOverride(env, homedir) {
177
+ for (const name of HOME_OVERRIDE_VARS) {
178
+ const value = envPath(env, homedir, name)
179
+ if (value !== null) return value
180
+ }
181
+ return null
182
+ }
183
+
184
+ /** The collector's state directory: `SANDGARDEN_HOME` → `GLORIA_HOME` →
185
+ * `$XDG_CONFIG_HOME/sandgarden` → `~/.config/sandgarden`. */
186
+ export function collectorHome(env, homedir) {
187
+ return (
188
+ homeOverride(env, homedir) ??
189
+ join(envPath(env, homedir, "XDG_CONFIG_HOME") ?? join(homedir, ".config"), "sandgarden")
190
+ )
191
+ }
192
+
193
+ /** The id in a machine.json, or null when there is no file. Refuses a file
194
+ * it can't use: replacing it would give the machine a second identity. */
195
+ function readMachineFile(file) {
196
+ if (!existsSync(file)) return null
197
+ let machineId
198
+ try {
199
+ machineId = JSON.parse(readFileSync(file, "utf8"))?.machineId
200
+ } catch {
201
+ machineId = undefined
202
+ }
203
+ if (typeof machineId !== "string" || !MACHINE_ID_PATTERN.test(machineId)) {
204
+ throw refuse(
205
+ `${file} doesn't hold a lowercase UUID machineId, so it was left alone. Replacing it ` +
206
+ "would give this machine a second identity. Fix or remove it, then re-run.",
207
+ )
208
+ }
209
+ return machineId
210
+ }
211
+
212
+ /**
213
+ * This machine's id: the collector's `machine.json`; else, before the
214
+ * collector's one-time migration has run, the pre-rename `~/.gloria` id (read
215
+ * only: writing machine.json would close the migration gate and strand the
216
+ * legacy config.json); else a fresh UUID v4 written in the collector's
217
+ * format, so a collector installed later reuses it.
218
+ */
219
+ export function resolveMachineId({ env, homedir }) {
220
+ const home = collectorHome(env, homedir)
221
+ const file = join(home, "machine.json")
222
+ const existing = readMachineFile(file)
223
+ if (existing !== null) return { machineId: existing, source: "existing", file }
224
+
225
+ const legacyFile = join(homedir, ".gloria", "machine.json")
226
+ const migrationPending =
227
+ homeOverride(env, homedir) === null &&
228
+ !MIGRATION_GATE_ENTRIES.some((name) => existsSync(join(home, name)))
229
+ if (migrationPending) {
230
+ const legacy = readMachineFile(legacyFile)
231
+ if (legacy !== null) return { machineId: legacy, source: "legacy", file: legacyFile }
232
+ }
233
+
234
+ const machineId = randomUUID()
235
+ mkdirSync(home, { recursive: true })
236
+ const tmp = `${file}.${randomUUID()}.tmp`
237
+ try {
238
+ writeFileSync(tmp, JSON.stringify({ machineId }, null, 2))
239
+ renameSync(tmp, file)
240
+ } finally {
241
+ rmSync(tmp, { force: true })
242
+ }
243
+ return { machineId, source: "minted", file }
244
+ }
245
+
246
+ // --- header lists -----------------------------------------------------------
247
+ //
248
+ // Each agent keeps its headers in a different format, but all of them are an
249
+ // ordered list of { name, value } entries. Entries the merge doesn't touch are
250
+ // returned as the same objects, so a format can keep their original text.
251
+
252
+ function isNamed(entry, name) {
253
+ return entry.name.toLowerCase() === name
254
+ }
255
+
256
+ /** Parse a `cf-aig-metadata` value as a JSON object, or null. */
257
+ function parseMetadata(value) {
258
+ try {
259
+ const parsed = JSON.parse(value)
260
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null
261
+ } catch {
262
+ return null
263
+ }
264
+ }
265
+
266
+ /** Miranda's `cf-aig-metadata` entries merged into the customer's, Miranda's
267
+ * first: behind Cloudflare Access, `cf.user_id` evicts the LAST entry. */
268
+ function mergedMetadata(existing, { machineId, project, agent }) {
269
+ let customer = {}
270
+ if (existing !== undefined) {
271
+ const parsed = parseMetadata(existing.value)
272
+ if (parsed === null) {
273
+ throw refuse(
274
+ `The existing ${CF_METADATA_HEADER} header isn't a JSON object, so it was left alone. ` +
275
+ "Fix it, then re-run.",
276
+ )
277
+ }
278
+ customer = Object.fromEntries(
279
+ Object.entries(parsed).filter(([key]) => !MIRANDA_CF_KEYS.has(key)),
280
+ )
281
+ }
282
+ const merged = {
283
+ [CF_MACHINE_KEY]: machineId,
284
+ [CF_PROJECT_KEY]: project,
285
+ [CF_AGENT_KEY]: agent,
286
+ ...customer,
287
+ }
288
+ if (Object.keys(merged).length > CF_MAX_ENTRIES) {
289
+ throw refuse(
290
+ `${CF_METADATA_HEADER} holds at most five entries. Miranda needs three, and this agent ` +
291
+ `already sends ${Object.keys(customer).length} of its own (${Object.keys(customer).join(", ")}). ` +
292
+ "Remove enough of them to leave room, then re-run. Nothing was written.",
293
+ )
294
+ }
295
+ return JSON.stringify(merged)
296
+ }
297
+
298
+ /** The customer's `cf-aig-metadata` with any Miranda entries removed: the
299
+ * entry unchanged when it has none, null when nothing of theirs is left. */
300
+ function withoutMirandaMetadata(entry) {
301
+ const parsed = parseMetadata(entry.value)
302
+ if (parsed === null || !Object.keys(parsed).some((key) => MIRANDA_CF_KEYS.has(key))) return entry
303
+ const customer = Object.entries(parsed).filter(([key]) => !MIRANDA_CF_KEYS.has(key))
304
+ return customer.length === 0
305
+ ? null
306
+ : { name: entry.name, value: JSON.stringify(Object.fromEntries(customer)) }
307
+ }
308
+
309
+ /**
310
+ * Merge the gateway's carrier into an agent's header list. Every Miranda
311
+ * carrier already there is removed first; the customer's own headers are
312
+ * kept, in order.
313
+ */
314
+ export function mergeCarrierHeaders(entries, { gateway, machineId, project, agent }) {
315
+ const kind = carrierKind(gateway)
316
+ if (kind === "otel") throw refuse("OTLP is carried by resource attributes, not headers.")
317
+ const kept = entries.filter((entry) => !MIRANDA_HEADER_NAMES.has(entry.name.toLowerCase()))
318
+ if (kind === "cf-aig-metadata") {
319
+ const existing = kept.find((entry) => isNamed(entry, CF_METADATA_HEADER))
320
+ const value = mergedMetadata(existing, { machineId, project, agent })
321
+ return [
322
+ ...kept.filter((entry) => !isNamed(entry, CF_METADATA_HEADER)),
323
+ { name: CF_METADATA_HEADER, value },
324
+ ]
325
+ }
326
+ const [machineHeader, projectHeader] = HEADER_CARRIERS[gateway]
327
+ return [
328
+ ...kept
329
+ .map((entry) => (isNamed(entry, CF_METADATA_HEADER) ? withoutMirandaMetadata(entry) : entry))
330
+ .filter((entry) => entry !== null),
331
+ { name: machineHeader, value: machineId },
332
+ { name: projectHeader, value: project },
333
+ ]
334
+ }
335
+
336
+ /** `Name: Value` lines, as Claude Code's `ANTHROPIC_CUSTOM_HEADERS` holds
337
+ * them. Each entry keeps its original line. */
338
+ function parseHeaderLines(text) {
339
+ return text
340
+ .split("\n")
341
+ .filter((line) => line.trim() !== "")
342
+ .map((line) => {
343
+ const colon = line.indexOf(":")
344
+ return colon === -1
345
+ ? { name: line.trim(), value: "", line }
346
+ : { name: line.slice(0, colon).trim(), value: line.slice(colon + 1).trim(), line }
347
+ })
348
+ }
349
+
350
+ function formatHeaderLines(entries) {
351
+ return entries.map((entry) => entry.line ?? `${entry.name}: ${entry.value}`).join("\n")
352
+ }
353
+
354
+ // --- OTEL resource attributes -----------------------------------------------
355
+
356
+ /** OpenTelemetry's baggage-octet set, minus `%` (the escape itself). Anything
357
+ * else in a value is percent-encoded. */
358
+ function isBaggageOctet(char) {
359
+ const code = char.charCodeAt(0)
360
+ return (
361
+ code === 0x21 ||
362
+ (code >= 0x23 && code <= 0x2b && code !== 0x25) ||
363
+ (code >= 0x2d && code <= 0x3a) ||
364
+ (code >= 0x3c && code <= 0x5b) ||
365
+ (code >= 0x5d && code <= 0x7e)
366
+ )
367
+ }
368
+
369
+ function encodeAttributeValue(value) {
370
+ return [...value].map((char) => (isBaggageOctet(char) ? char : encodeURIComponent(char))).join("")
371
+ }
372
+
373
+ function attributeKey(entry) {
374
+ const separator = entry.indexOf("=")
375
+ const raw = (separator === -1 ? entry : entry.slice(0, separator)).trim()
376
+ try {
377
+ return decodeURIComponent(raw)
378
+ } catch {
379
+ return raw
380
+ }
381
+ }
382
+
383
+ /** Merge Miranda's three attributes into an `OTEL_RESOURCE_ATTRIBUTES`
384
+ * list, keeping the customer's entries verbatim. */
385
+ export function mergeResourceAttributes(existing, { machineId, project, agent }) {
386
+ const kept = existing
387
+ .split(",")
388
+ .filter((entry) => entry.trim() !== "" && !MIRANDA_OTEL_KEYS.has(attributeKey(entry)))
389
+ const added = [
390
+ [OTEL_MACHINE_KEY, machineId],
391
+ [OTEL_PROJECT_KEY, project],
392
+ [OTEL_AGENT_KEY, agent],
393
+ ].map(([key, value]) => `${key}=${encodeAttributeValue(value)}`)
394
+ return [...kept, ...added].join(",")
395
+ }
396
+
397
+ // --- JSON config files ------------------------------------------------------
398
+
399
+ function readJsonObject(file) {
400
+ if (!existsSync(file)) return {}
401
+ const text = readFileSync(file, "utf8")
402
+ let parsed
403
+ try {
404
+ parsed = JSON.parse(text)
405
+ } catch {
406
+ throw refuse(`${file} isn't valid JSON, so it was left alone. Fix or remove it, then re-run.`)
407
+ }
408
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
409
+ throw refuse(`${file} isn't a JSON object, so it was left alone.`)
410
+ }
411
+ return parsed
412
+ }
413
+
414
+ /** Write `next` only if it differs from what is on disk. */
415
+ function writeJsonIfChanged(file, before, next) {
416
+ if (existsSync(file) && JSON.stringify(before) === JSON.stringify(next)) {
417
+ return { status: "unchanged", file }
418
+ }
419
+ mkdirSync(dirname(file), { recursive: true })
420
+ writeFileSync(file, JSON.stringify(next, null, 2) + "\n")
421
+ return { status: "written", file }
422
+ }
423
+
424
+ // --- Claude Code ------------------------------------------------------------
425
+
426
+ /**
427
+ * Claude Code: `env.ANTHROPIC_CUSTOM_HEADERS` in the repository's
428
+ * `.claude/settings.local.json` (per user, per project, kept out of git).
429
+ * Claude Code resolves `env` by settings precedence with no merge, so both
430
+ * values go here together, merged with whatever headers the file already
431
+ * sends. The committed `.claude/settings.json` is never touched. OTLP's
432
+ * carrier is `OTEL_RESOURCE_ATTRIBUTES` in the same `env` block.
433
+ */
434
+ export function writeClaudeCodeCarrier({ repoRoot, gateway, machineId, project }) {
435
+ const values = { gateway, machineId, project, agent: "claude-code" }
436
+ const kind = carrierKind(gateway)
437
+ validateCarrierValues(values)
438
+ const file = join(repoRoot, ".claude", "settings.local.json")
439
+ const before = readJsonObject(file)
440
+ const env = { ...(before.env ?? {}) }
441
+ if (kind === "otel") {
442
+ env.OTEL_RESOURCE_ATTRIBUTES = mergeResourceAttributes(
443
+ env.OTEL_RESOURCE_ATTRIBUTES ?? "",
444
+ values,
445
+ )
446
+ } else {
447
+ const headers = parseHeaderLines(env.ANTHROPIC_CUSTOM_HEADERS ?? "")
448
+ env.ANTHROPIC_CUSTOM_HEADERS = formatHeaderLines(mergeCarrierHeaders(headers, values))
449
+ }
450
+ return writeJsonIfChanged(file, before, { ...before, env })
451
+ }
452
+
453
+ /** OTLP's carrier is an environment variable of the exporter; only Claude
454
+ * Code's settings file has an `env` block to hold it. */
455
+ function otlpNotInConfig(where) {
456
+ return refuse(
457
+ `${where} has no resource-attribute setting. Set OTEL_RESOURCE_ATTRIBUTES in the ` +
458
+ "environment of whatever exports your OTLP traces instead.",
459
+ )
460
+ }
461
+
462
+ // --- Codex ------------------------------------------------------------------
463
+ //
464
+ // Codex reads `model_providers` only from the user config ($CODEX_HOME,
465
+ // default ~/.codex): project `.codex/config.toml` ignores it. So the carrier
466
+ // goes on the customer's own custom provider block there. This is a narrow
467
+ // line editor, not a TOML parser: it rewrites one `http_headers = { … }`
468
+ // line, or inserts one, and refuses any shape it can't rewrite safely.
469
+
470
+ /** Provider ids Codex reserves; a custom block can't reuse them, so they
471
+ * can't carry headers. */
472
+ const CODEX_BUILTIN_PROVIDERS = new Set(["openai", "ollama", "lmstudio"])
473
+
474
+ const CODEX_CUSTOM_PROVIDER_GUIDANCE =
475
+ "Codex can only send extra headers from a custom provider: a [model_providers.<id>] " +
476
+ "block in config.toml whose base_url points at your gateway, selected with " +
477
+ 'model_provider = "<id>". Create that block (pointing Codex at the gateway is yours to ' +
478
+ "do), then re-run with its id."
479
+
480
+ /** A TOML basic string (double-quoted), escaped. */
481
+ function tomlString(value) {
482
+ return JSON.stringify(value)
483
+ }
484
+
485
+ /** A table header line's dotted key, as plain segments, or null. */
486
+ function tableHeaderKey(line) {
487
+ const match = /^\s*\[\s*([^[\]]+?)\s*\]\s*(#.*)?$/.exec(line)
488
+ if (match === null) return null
489
+ return [...match[1].matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+)/g)].map((part) =>
490
+ part[1] !== undefined ? JSON.parse(`"${part[1]}"`) : (part[2] ?? part[3]),
491
+ )
492
+ }
493
+
494
+ function sameKey(a, b) {
495
+ return a !== null && a.length === b.length && a.every((segment, i) => segment === b[i])
496
+ }
497
+
498
+ /** The top-level `model_provider`, read from before the first table. */
499
+ function activeCodexProvider(lines) {
500
+ for (const line of lines) {
501
+ if (/^\s*\[/.test(line)) return null
502
+ const match = /^\s*model_provider\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')/.exec(line)
503
+ if (match !== null) return match[1] !== undefined ? JSON.parse(`"${match[1]}"`) : match[2]
504
+ }
505
+ return null
506
+ }
507
+
508
+ /** Parse the inside of a one-line inline table of string values into header
509
+ * entries, or null if it isn't that shape. */
510
+ function parseInlineHeaders(body) {
511
+ const entries = []
512
+ const pair =
513
+ /\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+))\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*(,|$)/y
514
+ let index = 0
515
+ while (index < body.length && body.slice(index).trim() !== "") {
516
+ pair.lastIndex = index
517
+ const match = pair.exec(body)
518
+ if (match === null) return null
519
+ const name = match[1] !== undefined ? JSON.parse(`"${match[1]}"`) : (match[2] ?? match[3])
520
+ const value = match[4] !== undefined ? JSON.parse(`"${match[4]}"`) : match[5]
521
+ entries.push({ name, value })
522
+ index = pair.lastIndex
523
+ if (match[6] === "") break
524
+ }
525
+ return entries
526
+ }
527
+
528
+ function formatInlineHeaders(entries) {
529
+ const pairs = entries.map((entry) => `${tomlString(entry.name)} = ${tomlString(entry.value)}`)
530
+ return `{ ${pairs.join(", ")} }`
531
+ }
532
+
533
+ /**
534
+ * Codex: `http_headers` on the active custom `[model_providers.<id>]` block
535
+ * of `$CODEX_HOME/config.toml`, merged with the headers it already sends.
536
+ * `provider` overrides the top-level `model_provider`.
537
+ */
538
+ export function writeCodexCarrier({ codexHome, gateway, machineId, project, provider }) {
539
+ const values = { gateway, machineId, project, agent: "codex" }
540
+ if (carrierKind(gateway) === "otel") throw otlpNotInConfig("Codex's config.toml")
541
+ validateCarrierValues(values)
542
+ const file = join(codexHome, "config.toml")
543
+ if (!existsSync(file)) {
544
+ throw refuse(
545
+ `${file} doesn't exist, so Codex is on its built-in provider. ${CODEX_CUSTOM_PROVIDER_GUIDANCE}`,
546
+ )
547
+ }
548
+ const text = readFileSync(file, "utf8")
549
+ const lines = text.split("\n")
550
+ const id = provider ?? activeCodexProvider(lines)
551
+ if (id === null || CODEX_BUILTIN_PROVIDERS.has(id)) {
552
+ throw refuse(
553
+ `Codex is using the built-in provider "${id ?? "openai"}", which can't carry headers. ` +
554
+ CODEX_CUSTOM_PROVIDER_GUIDANCE,
555
+ )
556
+ }
557
+
558
+ const blockKey = ["model_providers", id]
559
+ const start = lines.findIndex((line) => sameKey(tableHeaderKey(line), blockKey))
560
+ if (start === -1) {
561
+ throw refuse(`${file} has no [model_providers.${id}] block. ${CODEX_CUSTOM_PROVIDER_GUIDANCE}`)
562
+ }
563
+ if (lines.some((line) => sameKey(tableHeaderKey(line), [...blockKey, "http_headers"]))) {
564
+ throw refuse(
565
+ `[model_providers.${id}.http_headers] is a separate table, which this script doesn't ` +
566
+ "rewrite. Add the headers to it by hand (see the Codex page of Miranda's gateway docs).",
567
+ )
568
+ }
569
+ let end = lines.findIndex((line, i) => i > start && /^\s*\[/.test(line))
570
+ if (end === -1) end = lines.length
571
+
572
+ const headerLine = lines.findIndex(
573
+ (line, i) => i > start && i < end && /^\s*http_headers\s*=/.test(line),
574
+ )
575
+ let next
576
+ if (headerLine === -1) {
577
+ let last = end - 1
578
+ while (last > start && lines[last].trim() === "") last -= 1
579
+ const merged = mergeCarrierHeaders([], values)
580
+ next = [
581
+ ...lines.slice(0, last + 1),
582
+ `http_headers = ${formatInlineHeaders(merged)}`,
583
+ ...lines.slice(last + 1),
584
+ ]
585
+ } else {
586
+ const match = /^(\s*http_headers\s*=\s*)\{(.*)\}(\s*(#.*)?)$/.exec(lines[headerLine])
587
+ const existing = match === null ? null : parseInlineHeaders(match[2])
588
+ if (existing === null) {
589
+ throw refuse(
590
+ `The http_headers in [model_providers.${id}] isn't a one-line table of strings, which is ` +
591
+ "the only shape this script rewrites. Add the headers by hand (see the Codex page of " +
592
+ "Miranda's gateway docs).",
593
+ )
594
+ }
595
+ const merged = mergeCarrierHeaders(existing, values)
596
+ next = [...lines]
597
+ next[headerLine] = `${match[1]}${formatInlineHeaders(merged)}${match[3]}`
598
+ }
599
+
600
+ const nextText = next.join("\n")
601
+ if (nextText === text) return { status: "unchanged", file, provider: id }
602
+ writeFileSync(file, nextText)
603
+ return { status: "written", file, provider: id }
604
+ }
605
+
606
+ // --- OpenCode ---------------------------------------------------------------
607
+ //
608
+ // OpenCode merges its global and project configs, so headers on the
609
+ // repository's `opencode.json` provider entry merge with the provider the
610
+ // customer defined globally, and the project value stays per repository.
611
+
612
+ function providerIdsWithBaseUrl(config) {
613
+ return Object.entries(config.provider ?? {})
614
+ .filter(([, entry]) => typeof entry?.options?.baseURL === "string")
615
+ .map(([id]) => id)
616
+ }
617
+
618
+ /**
619
+ * OpenCode: `provider.<id>.options.headers` in the repository's
620
+ * `opencode.json`, merged with the headers it already sends. Without
621
+ * `provider`, the gateway provider is the one provider whose `baseURL` the
622
+ * project or global config sets.
623
+ */
624
+ export function writeOpenCodeCarrier({
625
+ repoRoot,
626
+ globalConfigFile,
627
+ gateway,
628
+ machineId,
629
+ project,
630
+ provider,
631
+ }) {
632
+ const values = { gateway, machineId, project, agent: "opencode" }
633
+ if (carrierKind(gateway) === "otel") throw otlpNotInConfig("OpenCode's opencode.json")
634
+ validateCarrierValues(values)
635
+ const file = join(repoRoot, "opencode.json")
636
+ const before = readJsonObject(file)
637
+
638
+ let id = provider
639
+ if (id === undefined) {
640
+ const candidates = [
641
+ ...new Set([
642
+ ...providerIdsWithBaseUrl(before),
643
+ ...providerIdsWithBaseUrl(readJsonObject(globalConfigFile)),
644
+ ]),
645
+ ]
646
+ if (candidates.length !== 1) {
647
+ throw refuse(
648
+ candidates.length === 0
649
+ ? "No provider in opencode.json sets options.baseURL, so there is no gateway provider to " +
650
+ "add headers to. Point a provider at your gateway first, then re-run."
651
+ : `More than one provider sets options.baseURL (${candidates.join(", ")}). Re-run naming ` +
652
+ "the one routed through your gateway.",
653
+ )
654
+ }
655
+ id = candidates[0]
656
+ }
657
+
658
+ const entry = before.provider?.[id] ?? {}
659
+ const options = entry.options ?? {}
660
+ const existing = Object.entries(options.headers ?? {}).map(([name, value]) => ({ name, value }))
661
+ const headers = Object.fromEntries(
662
+ mergeCarrierHeaders(existing, values).map((header) => [header.name, header.value]),
663
+ )
664
+ const next = {
665
+ ...before,
666
+ provider: { ...before.provider, [id]: { ...entry, options: { ...options, headers } } },
667
+ }
668
+ return { ...writeJsonIfChanged(file, before, next), provider: id }
669
+ }
670
+
671
+ // --- CLI --------------------------------------------------------------------
672
+
673
+ const USAGE = `Usage:
674
+ write-gateway-carriers.mjs machine-id
675
+ write-gateway-carriers.mjs write --agent <claude-code|codex|opencode> --gateway <${GATEWAYS.join("|")}>
676
+ [--repo <dir>] [--provider <id>] [--project <url>]
677
+ write-gateway-carriers.mjs gateway-for <base-url>
678
+
679
+ Prints one JSON object on success (exit 0). A refusal prints its reason on
680
+ stderr and exits 2, having written nothing. Bad arguments exit 1.`
681
+
682
+ const WRITE_OPTIONS = {
683
+ agent: { type: "string" },
684
+ gateway: { type: "string" },
685
+ repo: { type: "string" },
686
+ provider: { type: "string" },
687
+ project: { type: "string" },
688
+ }
689
+
690
+ /** The repository's `origin` URL, or null when it has none. */
691
+ function originUrl(repoRoot) {
692
+ try {
693
+ return execFileSync("git", ["-C", repoRoot, "remote", "get-url", "origin"], {
694
+ encoding: "utf8",
695
+ stdio: ["ignore", "pipe", "ignore"],
696
+ }).trim()
697
+ } catch {
698
+ return null
699
+ }
700
+ }
701
+
702
+ function writeFor(agent, { env, homedir, repoRoot, ...values }) {
703
+ if (agent === "claude-code") return writeClaudeCodeCarrier({ repoRoot, ...values })
704
+ if (agent === "codex") {
705
+ const codexHome = envPath(env, homedir, "CODEX_HOME") ?? join(homedir, ".codex")
706
+ return writeCodexCarrier({ codexHome, ...values })
707
+ }
708
+ if (agent === "opencode") {
709
+ const configHome = envPath(env, homedir, "XDG_CONFIG_HOME") ?? join(homedir, ".config")
710
+ const globalConfigFile = join(configHome, "opencode", "opencode.json")
711
+ return writeOpenCodeCarrier({ repoRoot, globalConfigFile, ...values })
712
+ }
713
+ throw refuse(`No config writer for agent "${agent}". Use one of: claude-code, codex, opencode.`)
714
+ }
715
+
716
+ function runWrite(args, { env, homedir }) {
717
+ const { values: flags } = parseArgs({ args, options: WRITE_OPTIONS, strict: true })
718
+ if (flags.agent === undefined || flags.gateway === undefined) return null
719
+ const repoRoot = flags.repo ?? process.cwd()
720
+ const remote = flags.project ?? originUrl(repoRoot)
721
+ if (remote === null || remote === "") {
722
+ throw refuse(`${repoRoot} has no origin remote, so there is no project URL to send.`)
723
+ }
724
+ const project = normalizeRepoUrl(remote)
725
+ // Validate before minting, so a refused run leaves no machine.json behind.
726
+ carrierKind(flags.gateway)
727
+ const { machineId } = resolveMachineId({ env, homedir })
728
+ const result = writeFor(flags.agent, {
729
+ env,
730
+ homedir,
731
+ repoRoot,
732
+ gateway: flags.gateway,
733
+ provider: flags.provider,
734
+ machineId,
735
+ project,
736
+ })
737
+ return { ...result, agent: flags.agent, gateway: flags.gateway, machineId, project }
738
+ }
739
+
740
+ function runCommand(args, deps) {
741
+ const [command, ...rest] = args
742
+ if (command === "machine-id" && rest.length === 0) return resolveMachineId(deps)
743
+ if (command === "gateway-for" && rest.length === 1) return { gateway: inferGateway(rest[0]) }
744
+ if (command === "write") return runWrite(rest, deps)
745
+ return null
746
+ }
747
+
748
+ /** Run the CLI. Returns the exit code. */
749
+ export function main(args, { env, homedir, stdout, stderr }) {
750
+ let result
751
+ try {
752
+ result = runCommand(args, { env, homedir })
753
+ } catch (error) {
754
+ if (error.name === "CarrierRefusal") {
755
+ stderr(error.message)
756
+ return 2
757
+ }
758
+ if (error.code?.startsWith("ERR_PARSE_ARGS")) result = null
759
+ else throw error
760
+ }
761
+ if (result === null) {
762
+ stderr(USAGE)
763
+ return 1
764
+ }
765
+ stdout(JSON.stringify(result, null, 2))
766
+ return 0
767
+ }
768
+
769
+ if (process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]) {
770
+ process.exitCode = main(process.argv.slice(2), {
771
+ env: process.env,
772
+ homedir: osHomedir(),
773
+ stdout: (text) => process.stdout.write(text + "\n"),
774
+ stderr: (text) => process.stderr.write(text + "\n"),
775
+ })
776
+ }