model-compare-opencode-plugin 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,97 @@
1
+ // src/opencode/storage.ts
2
+ // Versioned records in ctx.storage (design §7.2). OpenCode's plugin storage
3
+ // is scoped to the plugin; records carry a version so corrupt or
4
+ // unknown-version state is discarded instead of crashing the plugin.
5
+
6
+ import { validateStoredCatalog } from "../core/catalog"
7
+ import { fnv1aHex } from "../core/hash"
8
+ import type { Sourced } from "../core/types"
9
+
10
+ // The slice of OpenCode's ctx.storage the plugin relies on.
11
+ export type Storage = {
12
+ get(key: string): Promise<unknown>
13
+ set(key: string, value: unknown): Promise<void>
14
+ }
15
+
16
+ // Reads a versioned record. Returns null — never throws — when the key is
17
+ // missing, the value is not a JSON object, or `v` is missing or not 1 (§7.2:
18
+ // corrupt or unknown-version entries are discarded and treated as absent).
19
+ export async function readRecord<T extends { v: number }>(
20
+ storage: Storage,
21
+ key: string,
22
+ ): Promise<T | null> {
23
+ const raw: unknown = await storage.get(key)
24
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null
25
+ if ((raw as { v?: unknown }).v !== 1) return null
26
+ return raw as T
27
+ }
28
+
29
+ // Writes a versioned record. `value` already carries its `v`; the spread
30
+ // simply guarantees the written shape is { ...value, v }.
31
+ export async function writeRecord(
32
+ storage: Storage,
33
+ key: string,
34
+ value: { v: number } & Record<string, unknown>,
35
+ ): Promise<void> {
36
+ await storage.set(key, { ...value, v: value.v })
37
+ }
38
+
39
+ // ---- the document cache (§7.9) ------------------------------------------------
40
+ // The last validated catalog and when the plugin acquired it:
41
+ // { v: 1, source: <identity>, doc: Catalog, acquiredAt: number }
42
+ // stored per SOURCE IDENTITY, so a document is only ever served for the source
43
+ // that produced it: changing `catalogURL`, `scriptPath` or `source` is a cache
44
+ // miss, never a 6-hour stale answer from the previous source.
45
+
46
+ export const remoteIdentity = (url: string): string => `remote:${url}`
47
+ // The vendored copy has a fixed identity: its path changes with every install
48
+ // location/version, and a plugin update must not look like a cold start.
49
+ export const localIdentity = (scriptPath: string | null): string =>
50
+ `local:${scriptPath ?? "vendored"}`
51
+
52
+ export const docCacheKey = (identity: string): string => `doc/cache/${fnv1aHex(identity)}`
53
+
54
+ type DocCacheRecord = { v: number; source: unknown; doc: unknown; acquiredAt: unknown }
55
+
56
+ // The cached document for one source identity, or null when there is none, it
57
+ // belongs to another identity (a key collision), or it fails validation — a
58
+ // corrupt record is a cache miss, never an exception mid-setup.
59
+ export async function readCachedDoc(storage: Storage, identity: string): Promise<Sourced | null> {
60
+ const record = await readRecord<DocCacheRecord>(storage, docCacheKey(identity))
61
+ if (record === null) return null
62
+ if (record.source !== identity) return null
63
+ if (typeof record.acquiredAt !== "number") return null
64
+ const validated = validateStoredCatalog(record.doc)
65
+ if (!validated.ok) {
66
+ console.warn("[model-compare]", `discarding the cached catalog: ${validated.reason}`)
67
+ return null
68
+ }
69
+ return { doc: validated.catalog, acquiredAt: record.acquiredAt }
70
+ }
71
+
72
+ export async function writeCachedDoc(
73
+ storage: Storage,
74
+ identity: string,
75
+ sourced: Sourced,
76
+ ): Promise<void> {
77
+ await writeRecord(storage, docCacheKey(identity), {
78
+ v: 1,
79
+ source: identity,
80
+ doc: sourced.doc,
81
+ acquiredAt: sourced.acquiredAt,
82
+ })
83
+ }
84
+
85
+ // The most recently acquired valid document among the given identities (setup
86
+ // seeds from it; under `auto` either source may have produced it).
87
+ export async function readNewestCachedDoc(
88
+ storage: Storage,
89
+ identities: readonly string[],
90
+ ): Promise<Sourced | null> {
91
+ let newest: Sourced | null = null
92
+ for (const identity of identities) {
93
+ const cached = await readCachedDoc(storage, identity)
94
+ if (cached !== null && (newest === null || cached.acquiredAt > newest.acquiredAt)) newest = cached
95
+ }
96
+ return newest
97
+ }
@@ -0,0 +1,121 @@
1
+ // src/opencode/usebest.ts
2
+ // Shared /use-best decision + alias-view helpers (design §10). Pure module:
3
+ // no OpenCode imports, no I/O. Both the server command (src/index.ts) and the
4
+ // TUI entry (src/tui.tsx) consume it, so their decisions and messages can
5
+ // never drift apart.
6
+
7
+ import { parseUseBest, sanitizeCatalogText } from "./commands"
8
+ import type { Priority } from "../core/types"
9
+
10
+ export const PRIORITIES: readonly Priority[] = ["balanced", "price", "quality"]
11
+ export const ALIAS_PROVIDER = "openrouter"
12
+ export const aliasId = (p: Priority): string => `model-compare/${p}`
13
+ export const aliasRef = (p: Priority): string => `${ALIAS_PROVIDER}/${aliasId(p)}`
14
+
15
+ // The headless refusal text (§10; pinned by the integration suite's
16
+ // REFUSAL_TEXT).
17
+ export function useBestRefusal(p: Priority): string {
18
+ return `model-compare: ${aliasRef(p)} is currently disabled (no ranking data); not switching`
19
+ }
20
+
21
+ // The switch notice: the pick shown is the one the alias serves right now (a
22
+ // pin can differ from rank 1 — minLead hysteresis), sanitized (F-20); "null"
23
+ // means the caller could not observe a pick and says "unavailable".
24
+ export function useBestNotice(p: Priority, currentPickId: string | null): string {
25
+ const pick = currentPickId === null ? "unavailable" : sanitizeCatalogText(currentPickId)
26
+ return `model-compare: switched to ${aliasRef(p)} (currently ${pick})`
27
+ }
28
+
29
+ // The full decision for a /use-best invocation: argument parsing plus the
30
+ // enabled gate. `enabled` is the caller's registry view of each priority —
31
+ // the server command reads state.enabled, the TUI entry reads the model
32
+ // registry (which omits disabled aliases in some states and reports
33
+ // `enabled: false` in others; absence is read as disabled by the caller).
34
+ export function useBestDecision(input: {
35
+ argument: string
36
+ defaultPriority: Priority
37
+ enabled: (p: Priority) => boolean
38
+ }): { ok: true; priority: Priority } | { ok: false; message: string } {
39
+ const parsed = parseUseBest(input.argument, input.defaultPriority)
40
+ if (!parsed.ok) return parsed
41
+ if (!input.enabled(parsed.priority)) {
42
+ return { ok: false, message: useBestRefusal(parsed.priority) }
43
+ }
44
+ return parsed
45
+ }
46
+
47
+ // ---- registry views (the TUI side's data source) ---------------------------
48
+
49
+ // One model-compare alias as observed in the model registry.
50
+ export type AliasView = {
51
+ priority: Priority
52
+ name: string
53
+ enabled: boolean
54
+ modelID: string | null
55
+ }
56
+
57
+ // Extract the plugin's alias entries from a model-list response. Only entries
58
+ // present in the list are returned; a disabled alias is either omitted
59
+ // entirely or reported with enabled: false — callers must treat a missing
60
+ // view as disabled (probe-verified: list() omits disabled aliases).
61
+ export function readAliasViews(
62
+ entries: ReadonlyArray<{ id?: unknown; name?: unknown; enabled?: unknown; modelID?: unknown }>,
63
+ ): AliasView[] {
64
+ const prefix = "model-compare/"
65
+ const views: AliasView[] = []
66
+ for (const entry of entries) {
67
+ if (typeof entry.id !== "string" || !entry.id.startsWith(prefix)) continue
68
+ const priority = entry.id.slice(prefix.length)
69
+ if (!PRIORITIES.includes(priority as Priority)) continue
70
+ views.push({
71
+ priority: priority as Priority,
72
+ name: typeof entry.name === "string" ? entry.name : "",
73
+ enabled: entry.enabled !== false,
74
+ modelID: typeof entry.modelID === "string" ? entry.modelID : null,
75
+ })
76
+ }
77
+ return views.sort(
78
+ (x, y) => PRIORITIES.indexOf(x.priority) - PRIORITIES.indexOf(y.priority),
79
+ )
80
+ }
81
+
82
+ // The pick shown in an alias's display name:
83
+ // "Model Compare: <p> → <pick>[( fallback)]" → "<pick>[( fallback)]", or null
84
+ // for the "(unavailable)" placeholder names.
85
+ export function pickFromName(name: string): string | null {
86
+ const arrow = name.indexOf("→")
87
+ if (arrow === -1) return null
88
+ return name.slice(arrow + 1).trim()
89
+ }
90
+
91
+ // The pick an ENABLED alias view serves, or null when nothing observable is
92
+ // served: the display name is the truth (it mirrors the picker, including
93
+ // the "(fallback)" marker); modelID is the fallback when the name has no
94
+ // arrow; an empty name is also "nothing observable". Exported for the TUI
95
+ // half's picks dialog (statusLine's rules, one priority at a time). The
96
+ // result is a DISPLAY string only (footer, dialog, toasts) and is flattened
97
+ // here (F-20) as defense in depth: the name is normally built sanitized by
98
+ // planAlias, but modelID is registry-sourced and can carry raw catalog text.
99
+ export function pickOf(view: AliasView): string | null {
100
+ const pick = pickFromName(view.name) ?? view.modelID ?? (view.name === "" ? null : "unavailable")
101
+ return pick === null ? null : sanitizeCatalogText(pick)
102
+ }
103
+
104
+ // The priority column width for aligned pick rows: the longest priority
105
+ // name ("balanced"), so every arrow — and the pick after it — starts at the
106
+ // same column in the picks dialog: `price` gains three spaces before its
107
+ // arrow, `quality` one.
108
+ export function alignedPriority(priority: Priority): string {
109
+ return priority.padEnd(Math.max(...PRIORITIES.map((p) => p.length)))
110
+ }
111
+
112
+ // The compact footer line — the balanced pick only, e.g.
113
+ // `MC: balanced → xiaomi/mimo-v2.6-flash`. The full three-priority view
114
+ // lives behind the picks dialog (a select over the alias views);
115
+ // registry-absent and disabled aliases read "disabled", a present-but-
116
+ // unresolvable one reads "unavailable" — the line never vanishes.
117
+ export function statusLine(views: AliasView[]): string {
118
+ const view = views.find((item) => item.priority === "balanced")
119
+ if (view === undefined || view.enabled === false) return "MC: balanced → disabled"
120
+ return `MC: balanced → ${pickOf(view) ?? "unavailable"}`
121
+ }
package/src/rpc.ts ADDED
@@ -0,0 +1,78 @@
1
+ // src/rpc.ts
2
+ // The plugin's RPC surface (V2 plugins RPC): methods the TUI half and other
3
+ // clients can call on the server plugin, plus the event the /model-compare
4
+ // command emits. The report method returns the SAME text the command
5
+ // synthesizes — one builder in src/index.ts serves both, so the two can
6
+ // never drift. The report EVENT carries the structured entries (the TUI's
7
+ // selectable report dialog), because the transcript notice itself only
8
+ // surfaces at the session's next turn.
9
+ import { Rpc } from "@opencode/plugin/rpc"
10
+
11
+ export const ModelCompare = Rpc.define({
12
+ id: "model-compare",
13
+ methods: {
14
+ report: {
15
+ input: { type: "object", properties: {}, additionalProperties: false },
16
+ output: {
17
+ type: "object",
18
+ properties: {
19
+ text: { type: "string" },
20
+ age: { type: ["string", "null"] },
21
+ entries: {
22
+ type: "array",
23
+ items: {
24
+ type: "object",
25
+ properties: {
26
+ priority: { type: "string" },
27
+ rank: { type: ["integer", "null"] },
28
+ id: { type: "string" },
29
+ value: { type: "string" },
30
+ score: { type: "string" },
31
+ quality: { type: "string" },
32
+ price: { type: "string" },
33
+ current: { type: "boolean" },
34
+ fromFallback: { type: "boolean" },
35
+ },
36
+ required: ["priority", "rank", "id", "value", "score", "quality", "price", "current", "fromFallback"],
37
+ additionalProperties: false,
38
+ },
39
+ },
40
+ },
41
+ required: ["text", "age", "entries"],
42
+ additionalProperties: false,
43
+ },
44
+ },
45
+ },
46
+ events: {
47
+ report: {
48
+ schema: {
49
+ type: "object",
50
+ properties: {
51
+ sessionID: { type: "string" },
52
+ age: { type: ["string", "null"] },
53
+ entries: {
54
+ type: "array",
55
+ items: {
56
+ type: "object",
57
+ properties: {
58
+ priority: { type: "string" },
59
+ rank: { type: ["integer", "null"] },
60
+ id: { type: "string" },
61
+ value: { type: "string" },
62
+ score: { type: "string" },
63
+ quality: { type: "string" },
64
+ price: { type: "string" },
65
+ current: { type: "boolean" },
66
+ fromFallback: { type: "boolean" },
67
+ },
68
+ required: ["priority", "rank", "id", "value", "score", "quality", "price", "current", "fromFallback"],
69
+ additionalProperties: false,
70
+ },
71
+ },
72
+ },
73
+ required: ["sessionID", "age", "entries"],
74
+ additionalProperties: false,
75
+ },
76
+ },
77
+ },
78
+ })
@@ -0,0 +1,115 @@
1
+ // src/sources/index.ts
2
+ // Source selection and acquisition (design §3, §9): pick the catalog source
3
+ // and run it. "local" runs the configured-or-vendored script and rejects on
4
+ // failure — the caller keeps its last-known-good document. "auto" probes
5
+ // python first, runs local when it passes, and degrades to the remote catalog
6
+ // with an actionable warning when python is unusable or the local run fails
7
+ // (§3: warn and degrade, never throw from setup). "remote" fetches the
8
+ // published catalog without ever touching python. Only a failed remote
9
+ // fallback rejects.
10
+
11
+ import { fileURLToPath } from "node:url"
12
+ import type { Config } from "../config"
13
+ import type { Sourced } from "../core/types"
14
+ import { localIdentity, remoteIdentity, type Storage } from "../opencode/storage"
15
+ import { probePython, runLocalScript } from "./local"
16
+ import { fetchRemote } from "./remote"
17
+
18
+ // The vendored script, resolved from this module (src/sources/) back to the
19
+ // package root so it is found regardless of the process's working directory.
20
+ const VENDORED_SCRIPT = fileURLToPath(new URL("../../vendor/model_compare.py", import.meta.url))
21
+
22
+ // Injectable seams for tests; each defaults to the real implementation.
23
+ // storage is required because the remote path cannot proceed without it.
24
+ export type AcquireDeps = {
25
+ storage: Storage
26
+ probePython?: typeof probePython
27
+ runLocal?: typeof runLocalScript
28
+ fetchRemote?: typeof fetchRemote
29
+ }
30
+
31
+ export type Acquired = {
32
+ sourced: Sourced
33
+ source: "local" | "remote"
34
+ // The cache identity of the source that produced the document (storage.ts):
35
+ // the tick persists the document under it.
36
+ identity: string
37
+ fromCache: boolean
38
+ warnings: string[]
39
+ }
40
+
41
+ // runLocalScript prefixes every failure with this; the fallback warning
42
+ // re-wraps the cause instead of repeating the prefix.
43
+ const LOCAL_PREFIX = "local source failed: "
44
+
45
+ function asReason(error: unknown): string {
46
+ return error instanceof Error ? error.message : String(error)
47
+ }
48
+
49
+ export async function acquire(cfg: Config, deps: AcquireDeps): Promise<Acquired> {
50
+ const probe = deps.probePython ?? probePython
51
+ const runLocal = deps.runLocal ?? runLocalScript
52
+ const doFetch = deps.fetchRemote ?? fetchRemote
53
+
54
+ const scriptPath = cfg.scriptPath ?? VENDORED_SCRIPT
55
+
56
+ // Remote mode never probes python or runs the script (§9). fetchRemote's
57
+ // fromCache flag is lifted to the result; sourced itself stays a plain
58
+ // Sourced (§5).
59
+ if (cfg.source === "remote") {
60
+ const { doc, acquiredAt, fromCache } = await doFetch(cfg.catalogURL, deps.storage)
61
+ return {
62
+ sourced: { doc, acquiredAt },
63
+ source: "remote",
64
+ identity: remoteIdentity(cfg.catalogURL),
65
+ fromCache,
66
+ warnings: [],
67
+ }
68
+ }
69
+
70
+ if (cfg.source === "local") {
71
+ const sourced = await runLocal(scriptPath)
72
+ return { sourced, source: "local", identity: localIdentity(cfg.scriptPath), fromCache: false, warnings: [] }
73
+ }
74
+
75
+ // Auto: probe first; a usable python gets one local attempt. Either
76
+ // failure — the probe or the run — degrades to remote with the cause named.
77
+ const warnings: string[] = []
78
+ const probed = await probe()
79
+ if (probed.ok) {
80
+ try {
81
+ const sourced = await runLocal(scriptPath)
82
+ return {
83
+ sourced,
84
+ source: "local",
85
+ identity: localIdentity(cfg.scriptPath),
86
+ fromCache: false,
87
+ warnings: [],
88
+ }
89
+ } catch (error) {
90
+ const cause = asReason(error).replace(LOCAL_PREFIX, "")
91
+ warnings.push(`local source failed (${cause}); using remote catalog`)
92
+ }
93
+ } else {
94
+ warnings.push(`${probed.reason}; using remote catalog`)
95
+ }
96
+
97
+ const { doc, acquiredAt, fromCache } = await doFetch(cfg.catalogURL, deps.storage)
98
+ return {
99
+ sourced: { doc, acquiredAt },
100
+ source: "remote",
101
+ identity: remoteIdentity(cfg.catalogURL),
102
+ fromCache,
103
+ warnings,
104
+ }
105
+ }
106
+
107
+ // The cache identities a configuration can have produced a document under:
108
+ // setup seeds from the newest of them (under `auto` either source may have won).
109
+ export function cacheIdentities(cfg: Config): string[] {
110
+ const local = localIdentity(cfg.scriptPath)
111
+ const remote = remoteIdentity(cfg.catalogURL)
112
+ if (cfg.source === "local") return [local]
113
+ if (cfg.source === "remote") return [remote]
114
+ return [local, remote]
115
+ }
@@ -0,0 +1,175 @@
1
+ // src/sources/local.ts
2
+ // The local catalog source (design §9, §11): run the vendored script with
3
+ // execFile — no shell, a timeout, and a minimal environment — then parse and
4
+ // validate whatever it printed. Any failure (missing interpreter, non-zero
5
+ // exit, timeout, invalid JSON, rejected document) rejects as
6
+ // "local source failed: <reason>"; the exit code only refines the message,
7
+ // with stderr informing it (§9).
8
+
9
+ import { execFile as execFileCallback } from "node:child_process"
10
+ import { promisify } from "node:util"
11
+ import { validateCatalog } from "../core/catalog"
12
+ import type { Sourced } from "../core/types"
13
+
14
+ const execFile = promisify(execFileCallback)
15
+
16
+ const STDERR_TAIL_CHARS = 500
17
+
18
+ // execFile's default maxBuffer is 1 MiB — easily exceeded by a catalog
19
+ // stdout, and the default kill would report it as a generic failure. An
20
+ // explicit, generous cap keeps normal runs safe while bounding memory.
21
+ export const MAX_BUFFER_BYTES = 64 * 1024 * 1024 // 64 MiB
22
+
23
+ // The version probe prints one line; ten seconds is generous. A hung
24
+ // interpreter (a stuck version-manager shim) must not stall the refresh tick.
25
+ const PROBE_TIMEOUT_MS = 10_000
26
+
27
+ // Environment variables the script may need beyond HOME and PATH (design
28
+ // §11): its cache location, the AA key, proxy settings in both casings, and
29
+ // custom CA paths. Passed through only when set and non-empty.
30
+ const PASSTHROUGH_ENV = [
31
+ "XDG_CACHE_HOME",
32
+ "AA_API_KEY",
33
+ "HTTP_PROXY",
34
+ "HTTPS_PROXY",
35
+ "NO_PROXY",
36
+ "http_proxy",
37
+ "https_proxy",
38
+ "no_proxy",
39
+ "SSL_CERT_FILE",
40
+ "SSL_CERT_DIR",
41
+ ] as const
42
+
43
+ // The shape execFile rejects with on failure: `code` is the exit status for a
44
+ // non-zero exit, "ENOENT" when the interpreter is missing, and null when the
45
+ // child was killed by a signal; `stdout`/`stderr` carry what was collected.
46
+ type ExecFileError = Error & {
47
+ code?: string | number
48
+ killed?: boolean
49
+ signal?: NodeJS.Signals | null
50
+ stdout?: string
51
+ stderr?: string
52
+ }
53
+
54
+ // The script's environment (design §11): HOME and PATH from process.env
55
+ // (omitted when absent) plus the passthrough list when set and non-empty.
56
+ // Built fresh — process.env itself is never touched.
57
+ export function scriptEnv(): NodeJS.ProcessEnv {
58
+ const env: NodeJS.ProcessEnv = {}
59
+ for (const key of ["HOME", "PATH"] as const) {
60
+ const value = process.env[key]
61
+ if (value !== undefined) env[key] = value
62
+ }
63
+ for (const key of PASSTHROUGH_ENV) {
64
+ const value = process.env[key]
65
+ if (value !== undefined && value !== "") env[key] = value
66
+ }
67
+ return env
68
+ }
69
+
70
+ // The trimmed last chunk of stderr for failure messages; empty stderr
71
+ // contributes nothing to the message.
72
+ function stderrTail(stderr: string | undefined): string {
73
+ const text = (stderr ?? "").trim()
74
+ if (text === "") return ""
75
+ const tail = text.length > STDERR_TAIL_CHARS ? text.slice(-STDERR_TAIL_CHARS) : text
76
+ return `; stderr: ${tail}`
77
+ }
78
+
79
+ // Whether the plugin can run the vendored script at all: python3 present and
80
+ // at least 3.10 (design §9). The reason names what failed.
81
+ export async function probePython(
82
+ timeoutMs = PROBE_TIMEOUT_MS,
83
+ ): Promise<{ ok: true } | { ok: false; reason: string }> {
84
+ let version: string
85
+ try {
86
+ const { stdout } = await execFile(
87
+ "python3",
88
+ ["-c", "import sys; print('.'.join(map(str, sys.version_info[:2])))"],
89
+ { timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES },
90
+ )
91
+ version = stdout.trim()
92
+ } catch (error) {
93
+ const err = error as ExecFileError
94
+ if (err.code === "ENOENT") {
95
+ return { ok: false, reason: "python3 not found (spawn ENOENT)" }
96
+ }
97
+ if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
98
+ return { ok: false, reason: `python3 probe output exceeded the ${MAX_BUFFER_BYTES / (1024 * 1024)} MiB cap` }
99
+ }
100
+ if (err.killed && err.signal) {
101
+ return { ok: false, reason: `python3 probe timed out after ${timeoutMs}ms` }
102
+ }
103
+ return { ok: false, reason: `python3 could not be probed: ${err.message}` }
104
+ }
105
+
106
+ const match = /^(\d+)\.(\d+)$/.exec(version)
107
+ if (match === null) {
108
+ return {
109
+ ok: false,
110
+ reason: `python3 reported unparseable version info: ${JSON.stringify(version)}`,
111
+ }
112
+ }
113
+ const major = Number(match[1])
114
+ const minor = Number(match[2])
115
+ if (major < 3 || (major === 3 && minor < 10)) {
116
+ return { ok: false, reason: `python3 is ${major}.${minor}, need >= 3.10` }
117
+ }
118
+ return { ok: true }
119
+ }
120
+
121
+ // Run the script (an explicit path — resolving the vendored default is the
122
+ // caller's job), validate its stdout, and stamp the plugin's own acquisition
123
+ // time on the document (design §5, §9).
124
+ export async function runLocalScript(
125
+ scriptPath: string,
126
+ timeoutMs = 60_000,
127
+ ): Promise<Sourced> {
128
+ let stdout: string
129
+ try {
130
+ const result = await execFile("python3", [scriptPath, "--catalog"], {
131
+ env: scriptEnv(),
132
+ timeout: timeoutMs,
133
+ maxBuffer: MAX_BUFFER_BYTES,
134
+ })
135
+ stdout = result.stdout
136
+ } catch (error) {
137
+ const err = error as ExecFileError
138
+ const stderr = stderrTail(err.stderr)
139
+ if (err.code === "ENOENT") {
140
+ throw new Error(`local source failed: python3 not found (spawn ENOENT)${stderr}`)
141
+ }
142
+ // Checked before the signal branch: the maxBuffer kill also sets
143
+ // killed/signal, but its reason is distinct — the output cap, not the
144
+ // timeout.
145
+ if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
146
+ throw new Error(
147
+ `local source failed: script output exceeded the ${MAX_BUFFER_BYTES / (1024 * 1024)} MiB cap${stderr}`,
148
+ )
149
+ }
150
+ if (err.signal) {
151
+ const cause = err.killed
152
+ ? `timed out after ${timeoutMs}ms`
153
+ : `terminated by ${err.signal}`
154
+ throw new Error(`local source failed: ${cause} (killed with ${err.signal})${stderr}`)
155
+ }
156
+ if (typeof err.code === "number") {
157
+ throw new Error(`local source failed: exit code ${err.code}${stderr}`)
158
+ }
159
+ throw new Error(`local source failed: ${err.message}${stderr}`)
160
+ }
161
+
162
+ let raw: unknown
163
+ try {
164
+ raw = JSON.parse(stdout)
165
+ } catch (error) {
166
+ const message = error instanceof Error ? error.message : String(error)
167
+ throw new Error(`local source failed: invalid JSON on stdout (${message})`)
168
+ }
169
+
170
+ const validated = validateCatalog(raw)
171
+ if (!validated.ok) {
172
+ throw new Error(`local source failed: invalid catalog document: ${validated.reason}`)
173
+ }
174
+ return { doc: validated.catalog, acquiredAt: Date.now() }
175
+ }