@grosspoetrysystems/oompf 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -2,16 +2,152 @@
2
2
  import { createRequire } from "node:module";
3
3
  import { Cli, z } from "incur";
4
4
  import { dirname, isAbsolute, join } from "node:path";
5
+ import { spawn } from "node:child_process";
5
6
  import { createHash } from "node:crypto";
6
7
  import { access, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
7
8
  import { env } from "node:process";
8
- import { spawn } from "node:child_process";
9
+ import { confirm, isCancel, select } from "@clack/prompts";
9
10
  //#region \0rolldown/runtime.js
10
11
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
11
12
  var __require = /* #__PURE__ */ (() => createRequire(import.meta.url))();
12
13
  //#endregion
13
14
  //#region package.json
14
- var version = "0.2.1";
15
+ var version = "0.3.0";
16
+ //#endregion
17
+ //#region ../../packages/core/src/spawn.ts
18
+ /**
19
+ * The workspace's single process-spawning primitive.
20
+ *
21
+ * Backed by `node:child_process` rather than `Bun.spawn`. Bun implements the
22
+ * Node API, so one implementation serves both runtimes — whereas reaching for
23
+ * the `Bun` global makes the published CLI throw
24
+ * `Cannot read properties of undefined (reading 'spawn')` the moment it runs
25
+ * under Node, which is exactly how this module came to exist.
26
+ *
27
+ * Every caller passes an explicit argument array. There is no shell string and
28
+ * no interpolation, so profile names, descriptions, and file contents can never
29
+ * be reinterpreted as shell syntax.
30
+ *
31
+ * Import only from CLI-side code: spawning is unavailable inside a Cloudflare
32
+ * Worker.
33
+ */
34
+ /**
35
+ * Run a command, capturing its output.
36
+ *
37
+ * Rejects with the underlying spawn error when the executable is missing (an
38
+ * `ENOENT`), so callers can distinguish "not installed" from "ran and failed"
39
+ * — a non-zero exit code always means the binary ran.
40
+ */
41
+ function spawnCapture(input) {
42
+ const { args, command, env, stdin } = input;
43
+ const { promise, resolve, reject } = Promise.withResolvers();
44
+ const child = spawn(command, [...args], {
45
+ stdio: [
46
+ stdin === void 0 ? "ignore" : "pipe",
47
+ "pipe",
48
+ "pipe"
49
+ ],
50
+ ...env === void 0 ? {} : { env }
51
+ });
52
+ const { stderr: errStream, stdout: outStream } = child;
53
+ if (outStream === null || errStream === null) {
54
+ reject(/* @__PURE__ */ new Error(`Could not capture output from "${command}".`));
55
+ return promise;
56
+ }
57
+ let stdout = "";
58
+ let stderr = "";
59
+ outStream.setEncoding("utf8");
60
+ errStream.setEncoding("utf8");
61
+ outStream.on("data", (chunk) => {
62
+ stdout += chunk;
63
+ });
64
+ errStream.on("data", (chunk) => {
65
+ stderr += chunk;
66
+ });
67
+ child.on("error", reject);
68
+ child.on("close", (exitCode) => {
69
+ resolve({
70
+ exitCode,
71
+ stderr,
72
+ stdout
73
+ });
74
+ });
75
+ if (stdin !== void 0 && child.stdin !== null) {
76
+ child.stdin.on("error", () => void 0);
77
+ child.stdin.end(stdin, "utf8");
78
+ }
79
+ return promise;
80
+ }
81
+ //#endregion
82
+ //#region ../../packages/core/src/agent-runtime.ts
83
+ /**
84
+ * Agent-runtime detection and selection.
85
+ *
86
+ * OOMPF commands ask the local `omp` binary for profile paths. That binary is
87
+ * one of two agent runtimes — `omp` (OMP) or `pi` (Programmable Intelligence).
88
+ * This module resolves which binary a command should invoke: the installed
89
+ * runtime, the sole installed one, or an explicit `--agent` request.
90
+ *
91
+ * It selects the *binary* only. `pi`'s profile-path layout compatibility is
92
+ * tracked upstream (GPS-148) and is deliberately out of scope here — the
93
+ * chosen command simply flows through the existing `ompCommand` seam.
94
+ */
95
+ /** The executable name for each agent runtime. */
96
+ const AGENT_RUNTIME_COMMANDS = {
97
+ omp: "omp",
98
+ pi: "pi"
99
+ };
100
+ /** No usable agent runtime is installed (or the requested one is absent). */
101
+ var AgentRuntimeUnavailableError = class extends Error {
102
+ constructor(message) {
103
+ super(message);
104
+ this.name = "AgentRuntimeUnavailableError";
105
+ }
106
+ };
107
+ /** Default probe: the binary exists when `--version` runs at all. */
108
+ async function defaultProbe(command) {
109
+ try {
110
+ await spawnCapture({
111
+ args: ["--version"],
112
+ command
113
+ });
114
+ return true;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+ const INSTALL_BOTH = `Install either ${AGENT_RUNTIME_COMMANDS.omp} or ${AGENT_RUNTIME_COMMANDS.pi} and retry.`;
120
+ /**
121
+ * Resolve which agent-runtime binary a command should drive.
122
+ *
123
+ * Precedence:
124
+ * 1. `requested` set -> probe only that runtime. Present -> return it;
125
+ * absent -> throw {@link AgentRuntimeUnavailableError}.
126
+ * 2. `requested` unset -> probe both concurrently. Both present -> `omp`;
127
+ * exactly one -> that one; neither -> throw
128
+ * {@link AgentRuntimeUnavailableError}.
129
+ */
130
+ async function resolveAgentRuntime(options) {
131
+ const probe = options?.probe ?? defaultProbe;
132
+ if (options?.requested !== void 0) {
133
+ const requested = options.requested;
134
+ if (await probe(AGENT_RUNTIME_COMMANDS[requested])) return {
135
+ command: AGENT_RUNTIME_COMMANDS[requested],
136
+ runtime: requested
137
+ };
138
+ throw new AgentRuntimeUnavailableError(`The ${requested} agent runtime is not installed (expected the ${AGENT_RUNTIME_COMMANDS[requested]} binary on PATH). ${INSTALL_BOTH}`);
139
+ }
140
+ const [ompPresent, piPresent] = await Promise.all([probe(AGENT_RUNTIME_COMMANDS.omp), probe(AGENT_RUNTIME_COMMANDS.pi)]);
141
+ if (ompPresent) return {
142
+ command: AGENT_RUNTIME_COMMANDS.omp,
143
+ runtime: "omp"
144
+ };
145
+ if (piPresent) return {
146
+ command: AGENT_RUNTIME_COMMANDS.pi,
147
+ runtime: "pi"
148
+ };
149
+ throw new AgentRuntimeUnavailableError(`No agent runtime is installed (expected ${AGENT_RUNTIME_COMMANDS.omp} or ${AGENT_RUNTIME_COMMANDS.pi} on PATH). ${INSTALL_BOTH}`);
150
+ }
15
151
  //#endregion
16
152
  //#region ../../packages/core/src/guards.ts
17
153
  /**
@@ -374,6 +510,23 @@ function extractTags(value, warnings) {
374
510
  if (droppedNonString) warnings.push("oompf.tags dropped one or more non-string entries.");
375
511
  return tags;
376
512
  }
513
+ /** Keep a link only when its URL is a safe `http(s)` target. */
514
+ function httpLink(url, label, warnings) {
515
+ let protocol;
516
+ try {
517
+ protocol = new URL(url).protocol;
518
+ } catch {
519
+ protocol = "";
520
+ }
521
+ if (protocol !== "http:" && protocol !== "https:") {
522
+ warnings.push("oompf.links dropped an entry with a non-http(s) URL.");
523
+ return null;
524
+ }
525
+ return {
526
+ label,
527
+ url
528
+ };
529
+ }
377
530
  /** Coerce a single link entry (string URL or `{ url, label? }`) into a link. */
378
531
  function extractLink(entry, warnings) {
379
532
  if (typeof entry === "string") {
@@ -382,10 +535,7 @@ function extractLink(entry, warnings) {
382
535
  warnings.push("oompf.links dropped an entry with an empty URL.");
383
536
  return null;
384
537
  }
385
- return {
386
- label: null,
387
- url
388
- };
538
+ return httpLink(url, null, warnings);
389
539
  }
390
540
  if (isRecord(entry)) {
391
541
  const rawUrl = entry.url;
@@ -393,10 +543,8 @@ function extractLink(entry, warnings) {
393
543
  warnings.push("oompf.links dropped an entry missing a string `url`.");
394
544
  return null;
395
545
  }
396
- return {
397
- label: typeof entry.label === "string" && entry.label.trim() !== "" ? entry.label.trim() : null,
398
- url: rawUrl.trim()
399
- };
546
+ const label = typeof entry.label === "string" && entry.label.trim() !== "" ? entry.label.trim() : null;
547
+ return httpLink(rawUrl.trim(), label, warnings);
400
548
  }
401
549
  warnings.push("oompf.links dropped an entry that was not a string or object.");
402
550
  return null;
@@ -447,6 +595,43 @@ function extractMetadata(document) {
447
595
  warnings
448
596
  };
449
597
  }
598
+ //#endregion
599
+ //#region ../../packages/core/src/model-catalog.ts
600
+ /**
601
+ * GPS-150-1 is the first reviewed snapshot. A catalog revision changes only
602
+ * when its mappings change in a normal code review; upgrade output reports it
603
+ * so a proposed change remains attributable and reproducible.
604
+ */
605
+ const MODEL_CATALOG = {
606
+ models: [{
607
+ deployment: "hosted",
608
+ docsUrl: "https://docs.anthropic.com/en/docs/about-claude/models",
609
+ id: "anthropic/claude-opus-4",
610
+ providerId: "anthropic",
611
+ reasoning: "reasoning",
612
+ roles: [
613
+ "coding",
614
+ "planning",
615
+ "review"
616
+ ],
617
+ successor: "anthropic/claude-opus-4.8",
618
+ tier: "frontier"
619
+ }, {
620
+ deployment: "hosted",
621
+ docsUrl: "https://docs.anthropic.com/en/docs/about-claude/models",
622
+ id: "anthropic/claude-opus-4.8",
623
+ providerId: "anthropic",
624
+ reasoning: "reasoning",
625
+ roles: [
626
+ "coding",
627
+ "planning",
628
+ "review"
629
+ ],
630
+ successor: null,
631
+ tier: "frontier"
632
+ }],
633
+ revision: "gps-150-1"
634
+ };
450
635
  /**
451
636
  * Charset/shape rule. The leading class forbids a name starting with `.`, `_`,
452
637
  * or `-`; the `{0,63}` bound caps the total length at 64 characters.
@@ -499,71 +684,6 @@ function validateProfileName(name) {
499
684
  };
500
685
  }
501
686
  //#endregion
502
- //#region ../../packages/core/src/spawn.ts
503
- /**
504
- * The workspace's single process-spawning primitive.
505
- *
506
- * Backed by `node:child_process` rather than `Bun.spawn`. Bun implements the
507
- * Node API, so one implementation serves both runtimes — whereas reaching for
508
- * the `Bun` global makes the published CLI throw
509
- * `Cannot read properties of undefined (reading 'spawn')` the moment it runs
510
- * under Node, which is exactly how this module came to exist.
511
- *
512
- * Every caller passes an explicit argument array. There is no shell string and
513
- * no interpolation, so profile names, descriptions, and file contents can never
514
- * be reinterpreted as shell syntax.
515
- *
516
- * Import only from CLI-side code: spawning is unavailable inside a Cloudflare
517
- * Worker.
518
- */
519
- /**
520
- * Run a command, capturing its output.
521
- *
522
- * Rejects with the underlying spawn error when the executable is missing (an
523
- * `ENOENT`), so callers can distinguish "not installed" from "ran and failed"
524
- * — a non-zero exit code always means the binary ran.
525
- */
526
- function spawnCapture(input) {
527
- const { args, command, env, stdin } = input;
528
- const { promise, resolve, reject } = Promise.withResolvers();
529
- const child = spawn(command, [...args], {
530
- stdio: [
531
- stdin === void 0 ? "ignore" : "pipe",
532
- "pipe",
533
- "pipe"
534
- ],
535
- ...env === void 0 ? {} : { env }
536
- });
537
- const { stderr: errStream, stdout: outStream } = child;
538
- if (outStream === null || errStream === null) {
539
- reject(/* @__PURE__ */ new Error(`Could not capture output from "${command}".`));
540
- return promise;
541
- }
542
- let stdout = "";
543
- let stderr = "";
544
- outStream.setEncoding("utf8");
545
- errStream.setEncoding("utf8");
546
- outStream.on("data", (chunk) => {
547
- stdout += chunk;
548
- });
549
- errStream.on("data", (chunk) => {
550
- stderr += chunk;
551
- });
552
- child.on("error", reject);
553
- child.on("close", (exitCode) => {
554
- resolve({
555
- exitCode,
556
- stderr,
557
- stdout
558
- });
559
- });
560
- if (stdin !== void 0 && child.stdin !== null) {
561
- child.stdin.on("error", () => void 0);
562
- child.stdin.end(stdin, "utf8");
563
- }
564
- return promise;
565
- }
566
- //#endregion
567
687
  //#region ../../node_modules/.bun/yaml@2.9.0/node_modules/yaml/dist/nodes/identity.js
568
688
  var require_identity = /* @__PURE__ */ __commonJSMin(((exports) => {
569
689
  const ALIAS = Symbol.for("yaml.alias");
@@ -7254,6 +7374,17 @@ function assertProfileDocument(value) {
7254
7374
  * names — even hostile ones — are passed as opaque arguments with no shell
7255
7375
  * interpolation.
7256
7376
  */
7377
+ /** A named OMP profile whose resolved agent directory is absent. */
7378
+ var OmpProfileNotFoundError = class extends Error {
7379
+ profile;
7380
+ resolvedPath;
7381
+ constructor(profile, resolvedPath) {
7382
+ super(`Resolved agent directory for profile "${profile}" is not a directory (missing): "${resolvedPath}".`);
7383
+ this.profile = profile;
7384
+ this.resolvedPath = resolvedPath;
7385
+ this.name = "OmpProfileNotFoundError";
7386
+ }
7387
+ };
7257
7388
  const DEFAULT_OMP_COMMAND = "omp";
7258
7389
  /**
7259
7390
  * Config filenames OOMPF understands, in preference order: `config.yml` wins
@@ -7300,10 +7431,26 @@ async function resolveConfigPath(profile, ompCommand, requireDirectory) {
7300
7431
  if (!isAbsolute(resolved)) throw new Error(`OMP returned a non-absolute config path for the ${label}: "${resolved}".`);
7301
7432
  if (requireDirectory) {
7302
7433
  const kind = await statKind(resolved);
7434
+ if (kind === "missing" && profile !== null) throw new OmpProfileNotFoundError(profile, resolved);
7303
7435
  if (kind !== "directory") throw new Error(`Resolved agent directory for the ${label} is not a directory (${kind}): "${resolved}".`);
7304
7436
  }
7305
7437
  return resolved;
7306
7438
  }
7439
+ /**
7440
+ * Verify a named profile exists before invoking its OMP path resolver.
7441
+ *
7442
+ * OMP may create the named agent directory as a side effect of `config path`,
7443
+ * so checking only after that command turns an absent profile into a
7444
+ * config-less one. The default path is side-effect-free for named profiles and
7445
+ * gives us the same profiles root used by discovery.
7446
+ */
7447
+ async function requireExistingProfile(profile, ompCommand) {
7448
+ const defaultAgentDir = await resolveConfigPath(null, ompCommand, false);
7449
+ const agentDir = join(dirname(defaultAgentDir), "profiles", profile, "agent");
7450
+ const kind = await statKind(agentDir);
7451
+ if (kind === "missing") throw new OmpProfileNotFoundError(profile, agentDir);
7452
+ if (kind !== "directory") throw new Error(`Resolved agent directory for profile "${profile}" is not a directory (${kind}): "${agentDir}".`);
7453
+ }
7307
7454
  async function findConfigFile(agentDir) {
7308
7455
  for (const filename of CONFIG_FILENAMES$1) {
7309
7456
  const candidate = join(agentDir, filename);
@@ -7337,7 +7484,9 @@ async function resolveInstallTarget(name, options) {
7337
7484
  async function resolveProfileConfig(profile, options) {
7338
7485
  const validation = validateProfileName(profile);
7339
7486
  if (!validation.ok) throw new Error(validation.reason);
7340
- const agentDir = await resolveConfigPath(validation.value, options?.ompCommand ?? DEFAULT_OMP_COMMAND, true);
7487
+ const ompCommand = options?.ompCommand ?? DEFAULT_OMP_COMMAND;
7488
+ await requireExistingProfile(validation.value, ompCommand);
7489
+ const agentDir = await resolveConfigPath(validation.value, ompCommand, true);
7341
7490
  const configPath = await findConfigFile(agentDir);
7342
7491
  return {
7343
7492
  agentDir,
@@ -7380,6 +7529,182 @@ async function discoverProfiles(options) {
7380
7529
  return discovered;
7381
7530
  }
7382
7531
  //#endregion
7532
+ //#region ../../packages/core/src/upgrade.ts
7533
+ /** Thinking suffixes that belong to the model selector's execution settings. */
7534
+ const THINKING_LEVELS = {
7535
+ auto: true,
7536
+ high: true,
7537
+ inherit: true,
7538
+ low: true,
7539
+ max: true,
7540
+ medium: true,
7541
+ minimal: true,
7542
+ off: true,
7543
+ xhigh: true
7544
+ };
7545
+ function splitThinkingSuffix(model) {
7546
+ const colon = model.lastIndexOf(":");
7547
+ if (colon <= 0) return {
7548
+ modelSelector: model,
7549
+ thinkingLevel: null
7550
+ };
7551
+ const suffix = model.slice(colon + 1);
7552
+ return Object.hasOwn(THINKING_LEVELS, suffix) ? {
7553
+ modelSelector: model.slice(0, colon),
7554
+ thinkingLevel: suffix
7555
+ } : {
7556
+ modelSelector: model,
7557
+ thinkingLevel: null
7558
+ };
7559
+ }
7560
+ /** Map common profile role labels to the catalog's broader role-fit classes. */
7561
+ const ROLE_TO_CATALOG_ROLE = {
7562
+ chat: "chat",
7563
+ coder: "coding",
7564
+ coding: "coding",
7565
+ planner: "planning",
7566
+ planning: "planning",
7567
+ review: "review",
7568
+ reviewer: "review"
7569
+ };
7570
+ function replacementFor(model, path, role, catalog, plan) {
7571
+ if (model.startsWith("@")) return model;
7572
+ const parsed = splitThinkingSuffix(model);
7573
+ const current = catalog.models.find((entry) => entry.id === parsed.modelSelector);
7574
+ if (current === void 0) {
7575
+ plan.unchanged.push({
7576
+ model,
7577
+ path,
7578
+ reason: "unclassified",
7579
+ role
7580
+ });
7581
+ return model;
7582
+ }
7583
+ if (current.successor === null || current.successor === current.id) {
7584
+ plan.unchanged.push({
7585
+ model,
7586
+ path,
7587
+ reason: current.successor === current.id ? "already_current" : "no_successor",
7588
+ role
7589
+ });
7590
+ return model;
7591
+ }
7592
+ const successor = catalog.models.find((entry) => entry.id === current.successor);
7593
+ if (successor === void 0) {
7594
+ plan.unchanged.push({
7595
+ model,
7596
+ path,
7597
+ reason: "unavailable",
7598
+ role
7599
+ });
7600
+ return model;
7601
+ }
7602
+ const catalogRole = role === null ? void 0 : ROLE_TO_CATALOG_ROLE[role];
7603
+ if (catalogRole !== void 0 && !successor.roles.includes(catalogRole)) {
7604
+ plan.unchanged.push({
7605
+ model,
7606
+ path,
7607
+ reason: "slot_mismatch",
7608
+ role
7609
+ });
7610
+ return model;
7611
+ }
7612
+ const to = parsed.thinkingLevel === null ? successor.id : `${successor.id}:${parsed.thinkingLevel}`;
7613
+ plan.changes.push({
7614
+ from: model,
7615
+ path,
7616
+ reason: "successor",
7617
+ role,
7618
+ to
7619
+ });
7620
+ return to;
7621
+ }
7622
+ function replaceArray(values, displayPath, nodePath, role, catalog, plan, setNode) {
7623
+ for (let index = 0; index < values.length; index++) {
7624
+ const value = values[index];
7625
+ if (typeof value !== "string") continue;
7626
+ const next = replacementFor(value, `${displayPath}[${index}]`, role, catalog, plan);
7627
+ if (next !== value) setNode([...nodePath, index], next);
7628
+ }
7629
+ }
7630
+ function replaceModelRoles(document, catalog, plan, setNode) {
7631
+ const modelRoles = document.modelRoles;
7632
+ if (!isRecord(modelRoles)) return;
7633
+ for (const [role, assigned] of Object.entries(modelRoles)) {
7634
+ const displayPath = `modelRoles.${role}`;
7635
+ const nodePath = ["modelRoles", role];
7636
+ if (typeof assigned === "string") {
7637
+ const next = replacementFor(assigned, displayPath, role, catalog, plan);
7638
+ if (next !== assigned) setNode(nodePath, next);
7639
+ } else if (Array.isArray(assigned)) replaceArray(assigned, displayPath, nodePath, role, catalog, plan, setNode);
7640
+ }
7641
+ }
7642
+ function replaceEnabledModels(document, catalog, plan, setNode) {
7643
+ if (Array.isArray(document.enabledModels)) replaceArray(document.enabledModels, "enabledModels", ["enabledModels"], null, catalog, plan, setNode);
7644
+ }
7645
+ function replaceRetryChains(document, catalog, plan, setNode) {
7646
+ if (!isRecord(document.retry)) return;
7647
+ const chains = document.retry.fallbackChains;
7648
+ if (isRecord(chains)) {
7649
+ for (const [role, value] of Object.entries(chains)) if (Array.isArray(value)) replaceArray(value, `retry.fallbackChains.${role}`, [
7650
+ "retry",
7651
+ "fallbackChains",
7652
+ role
7653
+ ], null, catalog, plan, setNode);
7654
+ return;
7655
+ }
7656
+ if (!Array.isArray(chains)) return;
7657
+ if (chains.every((value) => typeof value === "string")) {
7658
+ replaceArray(chains, "retry.fallbackChains", ["retry", "fallbackChains"], null, catalog, plan, setNode);
7659
+ return;
7660
+ }
7661
+ for (let index = 0; index < chains.length; index++) {
7662
+ const value = chains[index];
7663
+ if (Array.isArray(value)) replaceArray(value, `retry.fallbackChains[${index}]`, [
7664
+ "retry",
7665
+ "fallbackChains",
7666
+ index
7667
+ ], null, catalog, plan, setNode);
7668
+ }
7669
+ }
7670
+ function applyReplacements(source, replacements) {
7671
+ let output = source;
7672
+ for (const replacement of [...replacements].sort((left, right) => right.start - left.start)) output = output.slice(0, replacement.start) + replacement.value + output.slice(replacement.end);
7673
+ return output;
7674
+ }
7675
+ /**
7676
+ * Produce a model-only YAML upgrade plan. The input document is never mutated;
7677
+ * only recognized model-selector locations are changed in the serialized copy.
7678
+ */
7679
+ function proposeUpgrade(yaml, catalog) {
7680
+ const document = (0, import_dist.parseDocument)(yaml, { keepSourceTokens: true });
7681
+ const parsed = document.toJS();
7682
+ if (!isRecord(parsed)) throw new Error("Profile YAML must have a mapping root.");
7683
+ const plan = {
7684
+ changes: [],
7685
+ unchanged: []
7686
+ };
7687
+ const replacements = [];
7688
+ const setNode = (path, value) => {
7689
+ const node = document.getIn(path, true);
7690
+ if (node === null || typeof node !== "object" || !("range" in node) || !Array.isArray(node.range) || typeof node.range[0] !== "number" || typeof node.range[1] !== "number") throw new Error("Profile YAML model selector has no source range.");
7691
+ replacements.push({
7692
+ end: node.range[1],
7693
+ start: node.range[0],
7694
+ value
7695
+ });
7696
+ };
7697
+ replaceModelRoles(parsed, catalog, plan, setNode);
7698
+ replaceEnabledModels(parsed, catalog, plan, setNode);
7699
+ replaceRetryChains(parsed, catalog, plan, setNode);
7700
+ return {
7701
+ catalogRevision: catalog.revision,
7702
+ changes: plan.changes,
7703
+ unchanged: plan.unchanged,
7704
+ yaml: applyReplacements(yaml, replacements)
7705
+ };
7706
+ }
7707
+ //#endregion
7383
7708
  //#region ../../packages/core/src/validation.ts
7384
7709
  /**
7385
7710
  * Structural validation and secret scanning for OMP profile artifacts.
@@ -7664,6 +7989,63 @@ function finishLocation(gistId, owner, revision, raw) {
7664
7989
  };
7665
7990
  }
7666
7991
  /**
7992
+ * Read a raw profile body under the size cap, refusing oversized payloads
7993
+ * before the full body is materialized. Prefers the `content-length` header;
7994
+ * when absent, streams the body and aborts once accumulated bytes exceed the
7995
+ * cap. In-cap content is decoded byte-exactly, so hashing is unchanged.
7996
+ */
7997
+ async function readRawYaml(rawResponse, gistId, filename) {
7998
+ const rawContentLength = rawResponse.headers instanceof Headers ? rawResponse.headers.get("content-length") : rawResponse.headers?.["content-length"];
7999
+ const contentLength = Number(rawContentLength);
8000
+ if (Number.isFinite(contentLength) && contentLength > 1048576) throw oversizedError(gistId, filename);
8001
+ const body = rawResponse.body;
8002
+ if (body === void 0 || body === null) return rawResponse.text();
8003
+ const reader = body.getReader();
8004
+ const chunks = [];
8005
+ let total = 0;
8006
+ for (;;) {
8007
+ const { done, value } = await reader.read();
8008
+ if (done) break;
8009
+ total += value.byteLength;
8010
+ if (total > 1048576) {
8011
+ await reader.cancel().catch(() => {});
8012
+ throw oversizedError(gistId, filename);
8013
+ }
8014
+ chunks.push(value);
8015
+ }
8016
+ const bytes = new Uint8Array(total);
8017
+ let offset = 0;
8018
+ for (const chunk of chunks) {
8019
+ bytes.set(chunk, offset);
8020
+ offset += chunk.byteLength;
8021
+ }
8022
+ return new TextDecoder().decode(bytes);
8023
+ }
8024
+ /** Value-free refusal shared by all oversized-payload rejection paths. */
8025
+ function oversizedError(gistId, filename) {
8026
+ return /* @__PURE__ */ new Error(`Gist "${gistId}" file "${filename}" exceeds the maximum supported artifact size.`);
8027
+ }
8028
+ /**
8029
+ * A Gist fetch that failed at the HTTP layer, carrying the response status.
8030
+ *
8031
+ * The status is what lets a caller tell "this source is gone" (404) from "we
8032
+ * could not ask right now" (403 rate limit, 429, 5xx). That distinction matters
8033
+ * to anything that records a judgement about a source: GitHub's unauthenticated
8034
+ * API allows 60 requests per hour per IP, and a Cloudflare Worker egresses from
8035
+ * shared addresses, so a 403 says nothing whatsoever about the Gist.
8036
+ *
8037
+ * Messages are unchanged from the plain `Error`s this replaced, so existing
8038
+ * message-based classification keeps working.
8039
+ */
8040
+ var GistHttpError = class extends Error {
8041
+ status;
8042
+ constructor(message, status) {
8043
+ super(message);
8044
+ this.name = "GistHttpError";
8045
+ this.status = status;
8046
+ }
8047
+ };
8048
+ /**
7667
8049
  * Fetch a public Gist and return its single canonical profile YAML source.
7668
8050
  *
7669
8051
  * The Gist metadata is read from `https://api.github.com/gists/<id>` (pinned
@@ -7672,8 +8054,10 @@ function finishLocation(gistId, owner, revision, raw) {
7672
8054
  * an unsupported filename is rejected. The file's exact bytes are then read
7673
8055
  * from its canonical `raw_url` and hashed.
7674
8056
  *
7675
- * @throws Error for unsupported references, missing/private Gists (404),
7676
- * ambiguous or unsupported YAML files, and transport failures.
8057
+ * @throws {GistHttpError} for HTTP-level failures, carrying the status (404 for
8058
+ * a missing or private Gist).
8059
+ * @throws Error for unsupported references, ambiguous or unsupported YAML
8060
+ * files, oversized payloads, and transport failures.
7677
8061
  */
7678
8062
  async function fetchPublicGist(source, options) {
7679
8063
  const location = parseGistLocation(source);
@@ -7681,16 +8065,16 @@ async function fetchPublicGist(source, options) {
7681
8065
  if (doFetch === void 0) throw new Error("No fetch implementation is available; inject one via options.fetch.");
7682
8066
  const metaResponse = await doFetch(location.revision === null ? `https://api.github.com/gists/${location.gistId}` : `https://api.github.com/gists/${location.gistId}/${location.revision}`, { headers: GITHUB_API_HEADERS });
7683
8067
  if (!metaResponse.ok) {
7684
- if (metaResponse.status === 404) throw new Error(`Public Gist "${location.gistId}" was not found. It may be private, deleted, or the ID may be wrong.`);
7685
- throw new Error(`Failed to fetch Gist "${location.gistId}": HTTP ${metaResponse.status}.`);
8068
+ if (metaResponse.status === 404) throw new GistHttpError(`Public Gist "${location.gistId}" was not found. It may be private, deleted, or the ID may be wrong.`, 404);
8069
+ throw new GistHttpError(`Failed to fetch Gist "${location.gistId}": HTTP ${metaResponse.status}.`, metaResponse.status);
7686
8070
  }
7687
8071
  const meta = parseGistMetadata(await metaResponse.text(), location.gistId);
7688
8072
  const yamlFile = selectYamlFile(meta.files, location.gistId);
7689
8073
  let content;
7690
8074
  if (yamlFile.rawUrl !== null) {
7691
8075
  const rawResponse = await doFetch(yamlFile.rawUrl, { headers: { "User-Agent": "oompf" } });
7692
- if (!rawResponse.ok) throw new Error(`Failed to fetch raw content for "${yamlFile.filename}" in Gist "${location.gistId}": HTTP ${rawResponse.status}.`);
7693
- content = await rawResponse.text();
8076
+ if (!rawResponse.ok) throw new GistHttpError(`Failed to fetch raw content for "${yamlFile.filename}" in Gist "${location.gistId}": HTTP ${rawResponse.status}.`, rawResponse.status);
8077
+ content = await readRawYaml(rawResponse, location.gistId, yamlFile.filename);
7694
8078
  } else if (yamlFile.content === null) throw new Error(`Gist "${location.gistId}" file "${yamlFile.filename}" exposed no raw URL or content.`);
7695
8079
  else content = yamlFile.content;
7696
8080
  return {
@@ -7885,12 +8269,81 @@ async function createPublicProfileGist(input, options) {
7885
8269
  };
7886
8270
  }
7887
8271
  /** Find the first `gist.github.com` URL in `gh`'s stdout, or `null`. */
8272
+ /**
8273
+ * Patch one file in an existing public Gist through `gh api`.
8274
+ *
8275
+ * The helper never creates a new Gist: preserving the source URL is what
8276
+ * preserves the stable OOMPF profile identity during `upgrade`.
8277
+ */
8278
+ async function updatePublicProfileGist(input, options) {
8279
+ const ghCommand = options?.ghCommand ?? DEFAULT_GH_COMMAND;
8280
+ const runner = options?.runner ?? nodeCommandRunner;
8281
+ const body = JSON.stringify({ files: { [input.filename]: { content: input.content } } });
8282
+ let result;
8283
+ try {
8284
+ result = await runner({
8285
+ args: [
8286
+ "api",
8287
+ "--method",
8288
+ "PATCH",
8289
+ `gists/${input.gistId}`,
8290
+ "--input",
8291
+ "-"
8292
+ ],
8293
+ command: ghCommand,
8294
+ stdin: body
8295
+ });
8296
+ } catch (error) {
8297
+ if (isMissingExecutable(error)) throw new Error(`GitHub CLI (\`${ghCommand}\`) was not found on your PATH. Install it from https://cli.github.com/ and run \`gh auth login\`.`);
8298
+ throw error;
8299
+ }
8300
+ if (result.exitCode !== 0) {
8301
+ const detail = result.stderr.trim() || `exit code ${result.exitCode}`;
8302
+ throw new Error(`GitHub CLI failed to update the public Gist: ${detail}`);
8303
+ }
8304
+ let parsed;
8305
+ try {
8306
+ parsed = JSON.parse(result.stdout);
8307
+ } catch {
8308
+ throw new Error("GitHub CLI returned invalid JSON after updating the public Gist.");
8309
+ }
8310
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || !("id" in parsed) || typeof parsed.id !== "string" || !("html_url" in parsed) || typeof parsed.html_url !== "string") throw new Error("GitHub CLI response after updating the public Gist was incomplete.");
8311
+ const first = ("history" in parsed && Array.isArray(parsed.history) ? parsed.history : [])[0];
8312
+ const revision = first !== null && typeof first === "object" && !Array.isArray(first) && "version" in first && typeof first.version === "string" ? first.version : null;
8313
+ return {
8314
+ gistId: parsed.id,
8315
+ htmlUrl: parsed.html_url,
8316
+ revision
8317
+ };
8318
+ }
7888
8319
  function extractGistUrl(stdout) {
7889
8320
  const match = stdout.match(/https?:\/\/gist\.github\.com\/\S+/);
7890
8321
  if (match === null) return null;
7891
- return match[0].replace(/[).,]+$/, "");
8322
+ return match[0].replace(/[).,\]]+$/, "");
7892
8323
  }
7893
8324
  //#endregion
8325
+ //#region src/profile-selector.ts
8326
+ function isInteractiveProfileSession(options) {
8327
+ return options.ci === void 0 && options.stdinIsTTY === true && options.stdoutIsTTY === true;
8328
+ }
8329
+ const defaultProfileSelector = {
8330
+ isInteractive: () => isInteractiveProfileSession({
8331
+ ci: process.env.CI,
8332
+ stdinIsTTY: process.stdin.isTTY,
8333
+ stdoutIsTTY: process.stdout.isTTY
8334
+ }),
8335
+ selectProfile: async (names) => {
8336
+ const selected = await select({
8337
+ message: "Select a profile to publish",
8338
+ options: names.map((name) => ({
8339
+ label: name,
8340
+ value: name
8341
+ }))
8342
+ });
8343
+ return isCancel(selected) ? null : selected;
8344
+ }
8345
+ };
8346
+ //#endregion
7894
8347
  //#region src/deps.ts
7895
8348
  /**
7896
8349
  * Injectable seams shared by every OOMPF CLI command.
@@ -7924,6 +8377,10 @@ function toCliError(error, err) {
7924
8377
  code: err.code,
7925
8378
  message: err.message
7926
8379
  });
8380
+ if (err instanceof AgentRuntimeUnavailableError) return error({
8381
+ code: "agent_not_found",
8382
+ message: err.message
8383
+ });
7927
8384
  return error({
7928
8385
  code: "error",
7929
8386
  message: err instanceof Error ? err.message : String(err)
@@ -7964,6 +8421,8 @@ function resolveDeps(deps = {}) {
7964
8421
  gistFetch: deps.gistFetch,
7965
8422
  httpFetch: deps.httpFetch ?? defaultHttpFetch,
7966
8423
  ompCommand: deps.ompCommand,
8424
+ profileSelector: deps.profileSelector ?? defaultProfileSelector,
8425
+ resolveAgentRuntime: deps.resolveAgentRuntime ?? resolveAgentRuntime,
7967
8426
  resolveInstallTarget: deps.resolveInstallTarget ?? resolveInstallTarget,
7968
8427
  resolveProfileConfig: deps.resolveProfileConfig ?? resolveProfileConfig,
7969
8428
  runner: deps.runner
@@ -8020,8 +8479,10 @@ async function fetchProfileMetadata(baseUrl, id, fetchImpl) {
8020
8479
  return parseJson(response, "not_found");
8021
8480
  }
8022
8481
  /** Free-text search over the OOMPF index. */
8023
- async function searchProfiles(baseUrl, query, fetchImpl) {
8024
- const response = await request(fetchImpl, joinUrl(baseUrl, `/api/v1/search?q=${encodeURIComponent(query)}`), { method: "GET" });
8482
+ async function searchProfiles(baseUrl, query, fetchImpl, cursor) {
8483
+ let url = joinUrl(baseUrl, `/api/v1/search?q=${encodeURIComponent(query)}`);
8484
+ if (cursor) url += `&cursor=${encodeURIComponent(cursor)}`;
8485
+ const response = await request(fetchImpl, url, { method: "GET" });
8025
8486
  if (!response.ok) throw await envelopeError(response, "search_failed");
8026
8487
  return parseJson(response, "search_failed");
8027
8488
  }
@@ -8077,12 +8538,50 @@ const publishOutput = z.object({
8077
8538
  structural: z.enum(["valid", "invalid"]),
8078
8539
  warnings: z.array(z.string())
8079
8540
  });
8541
+ /** `upgrade` result: a reviewed model diff and optional new Gist revision. */
8542
+ const upgradeOutput = z.object({
8543
+ catalogRevision: z.string(),
8544
+ changes: z.array(z.object({
8545
+ from: z.string(),
8546
+ path: z.string(),
8547
+ reason: z.literal("successor"),
8548
+ role: z.string().nullable(),
8549
+ to: z.string()
8550
+ })),
8551
+ currentRevision: z.string(),
8552
+ oompfUrl: z.string(),
8553
+ unchanged: z.array(z.object({
8554
+ model: z.string(),
8555
+ path: z.string(),
8556
+ reason: z.enum([
8557
+ "already_current",
8558
+ "no_successor",
8559
+ "slot_mismatch",
8560
+ "unavailable",
8561
+ "unclassified"
8562
+ ]),
8563
+ role: z.string().nullable()
8564
+ })),
8565
+ updatedRevision: z.string().nullable()
8566
+ });
8567
+ /** A single machine-local prerequisite the installed profile needs. */
8568
+ const prerequisite = z.object({
8569
+ kind: z.enum([
8570
+ "provider",
8571
+ "environment",
8572
+ "project-overlay",
8573
+ "extension"
8574
+ ]),
8575
+ name: z.string(),
8576
+ reason: z.string()
8577
+ });
8080
8578
  /** `add` result: the installed profile and how to run it. */
8081
8579
  const addOutput = z.object({
8082
8580
  command: z.string().describe("Command to run OMP with this profile"),
8083
8581
  hash: z.string().describe("SHA-256 of the installed artifact"),
8084
8582
  name: z.string().describe("Local OMP profile name installed"),
8085
8583
  path: z.string().describe("Config file written"),
8584
+ prerequisites: z.array(prerequisite).optional().describe("Profiles' machine-local prerequisites (names and kinds only, never secret values); informational, install succeeds regardless"),
8086
8585
  revision: z.string().nullable(),
8087
8586
  source: z.string().describe("Resolved canonical source"),
8088
8587
  warnings: z.array(z.string())
@@ -8092,7 +8591,7 @@ const inspectOutput = z.object({
8092
8591
  aliases: z.array(z.string()).describe("Named model aliases (@-prefixed)"),
8093
8592
  errors: z.array(z.string()),
8094
8593
  hash: z.string(),
8095
- installCommand: z.string(),
8594
+ installCommand: z.string().optional(),
8096
8595
  metadata: profileMetadataOutput,
8097
8596
  models: z.array(z.string()),
8098
8597
  name: z.string(),
@@ -8121,6 +8620,7 @@ const searchResult = z.object({
8121
8620
  /** `search` result: the query plus its compact matches. */
8122
8621
  const searchOutput = z.object({
8123
8622
  count: z.number(),
8623
+ nextCursor: z.string().nullable().describe("Opaque cursor for the next page, or null on the last page"),
8124
8624
  query: z.string(),
8125
8625
  results: z.array(searchResult)
8126
8626
  });
@@ -8130,8 +8630,9 @@ const searchOutput = z.object({
8130
8630
  * `oompf add <ref> [--name <name>]` — install a shared profile as a native OMP
8131
8631
  * profile.
8132
8632
  *
8133
- * The reference may be an OOMPF URL/id, a public Gist URL, or a bare Gist id.
8134
- * The canonical YAML is fetched and validated before anything is written. The
8633
+ * The reference must be an OOMPF URL or profile id. OOMPF resolves the
8634
+ * canonical Gist revision and verifies its fingerprint before anything is
8635
+ * written. The
8135
8636
  * local name defaults to `<github-owner>-<profile-name>` (overridable with
8136
8637
  * `--name`) and is checked against OMP's own naming rules. The install target
8137
8638
  * directory is resolved by asking OMP itself (`omp --profile <name> config
@@ -8147,22 +8648,27 @@ function filenameStem$1(filename) {
8147
8648
  /** Register the `add` command on the given CLI. */
8148
8649
  function registerAdd(cli, deps) {
8149
8650
  cli.command("add", {
8150
- args: z.object({ ref: z.string().describe("OOMPF URL/id, public Gist URL, or Gist id") }),
8651
+ args: z.object({ ref: z.string().describe("OOMPF URL or profile id") }),
8151
8652
  description: "Install a shared profile as a native OMP profile",
8152
8653
  env: cliEnv,
8153
8654
  examples: [{
8154
- args: { ref: "https://gist.github.com/octocat/abc123" },
8155
- description: "Install a profile from a public Gist"
8655
+ args: { ref: "https://oompf.run/p/prof_0123" },
8656
+ description: "Install a profile with pinned verification"
8156
8657
  }, {
8157
8658
  args: { ref: "https://oompf.run/p/prof_0123" },
8158
8659
  description: "Install under an explicit local name",
8159
8660
  options: { name: "work" }
8160
8661
  }],
8161
- options: z.object({ name: z.string().optional().describe("Local profile name (defaults to <owner>-<profile>)") }),
8662
+ options: z.object({
8663
+ agent: z.enum(["omp", "pi"]).optional().describe("Agent runtime to use (default: omp)"),
8664
+ name: z.string().optional().describe("Local profile name (defaults to <owner>-<profile>)")
8665
+ }),
8162
8666
  output: addOutput,
8163
8667
  async run(c) {
8164
8668
  try {
8165
8669
  const oompfId = parseOompfRef(c.args.ref);
8670
+ if (oompfId === null) throw new CommandError("unverifiable_artifact", "Install requires an OOMPF URL or profile id so OOMPF can verify the pinned revision and fingerprint.");
8671
+ const ompCommand = deps.ompCommand ?? (await deps.resolveAgentRuntime({ requested: c.options.agent })).command;
8166
8672
  let sourceUrl = c.args.ref;
8167
8673
  let fetchUrl = sourceUrl;
8168
8674
  let expectedHash = null;
@@ -8176,13 +8682,14 @@ function registerAdd(cli, deps) {
8176
8682
  const gist = await fetchPublicGist(fetchUrl, { fetch: deps.gistFetch });
8177
8683
  if (expectedHash !== null && gist.contentHash !== expectedHash) throw new CommandError("fingerprint_mismatch", "The pinned profile bytes do not match the indexed fingerprint. Refusing to install.");
8178
8684
  const validation = validateArtifact({ yaml: gist.content });
8179
- if (validation.structural === "invalid") throw new CommandError("invalid_artifact", `Refusing to install an invalid artifact: ${validation.errors.join("; ")}`);
8685
+ if (validation.structural === "invalid" || validation.facts === null) throw new CommandError("invalid_artifact", `Refusing to install an invalid artifact: ${validation.errors.join("; ")}`);
8686
+ if (validation.blocking.length > 0) throw new CommandError("blocking_secrets", `Refusing to install: high-confidence secrets detected at ${validation.blocking.map((f) => f.path).join(", ")}. Remove them and retry.`);
8180
8687
  const stem = filenameStem$1(gist.filename);
8181
8688
  const candidate = c.options.name ?? (gist.owner === null ? stem : `${gist.owner.toLowerCase()}-${stem}`);
8182
8689
  const nameCheck = validateProfileName(candidate);
8183
8690
  if (!nameCheck.ok) throw new CommandError("invalid_name", `Profile name "${candidate}" is invalid: ${nameCheck.reason} Pass --name <name>.`);
8184
8691
  const name = nameCheck.value;
8185
- const agentDir = await deps.resolveInstallTarget(name, { ompCommand: deps.ompCommand });
8692
+ const agentDir = await deps.resolveInstallTarget(name, { ompCommand });
8186
8693
  for (const filename of CONFIG_FILENAMES) {
8187
8694
  const existing = join(agentDir, filename);
8188
8695
  if (await deps.fs.exists(existing)) throw new CommandError("target_exists", `Profile "${name}" already has a config at ${existing}. Refusing to overwrite.`);
@@ -8191,6 +8698,7 @@ function registerAdd(cli, deps) {
8191
8698
  const target = join(agentDir, "config.yml");
8192
8699
  await deps.fs.writeFile(target, gist.content, 384);
8193
8700
  const command = `omp --profile ${name}`;
8701
+ const prerequisites = validation.facts.prerequisites;
8194
8702
  return c.ok({
8195
8703
  command,
8196
8704
  hash: gist.contentHash,
@@ -8198,11 +8706,9 @@ function registerAdd(cli, deps) {
8198
8706
  path: target,
8199
8707
  revision: gist.revision,
8200
8708
  source: sourceUrl,
8201
- warnings: [...validation.warnings]
8202
- }, { cta: {
8203
- commands: [command],
8204
- description: "Run it with:"
8205
- } });
8709
+ warnings: [...validation.warnings],
8710
+ ...prerequisites.length > 0 ? { prerequisites: [...prerequisites] } : {}
8711
+ });
8206
8712
  } catch (error) {
8207
8713
  return toCliError(c.error, error);
8208
8714
  }
@@ -8217,8 +8723,8 @@ function registerAdd(cli, deps) {
8217
8723
  * The reference may be an OOMPF URL/id or a public Gist URL/id. An OOMPF ref is
8218
8724
  * answered from the index metadata; a Gist ref is fetched and validated live.
8219
8725
  * Only displayable metadata — source, revision/hash, structural verdict, facts,
8220
- * and the install command — is printed. The canonical artifact content is never
8221
- * emitted.
8726
+ * and the OOMPF install command — is printed. The canonical artifact content is
8727
+ * never emitted.
8222
8728
  */
8223
8729
  /** Strip a recognised YAML extension from a Gist filename to get the stem. */
8224
8730
  function filenameStem(filename) {
@@ -8261,7 +8767,7 @@ function registerInspect(cli, deps) {
8261
8767
  structural: record.validation.structural,
8262
8768
  warnings: [...record.validation.warnings]
8263
8769
  }, { cta: {
8264
- commands: [`oompf add ${c.args.ref}`],
8770
+ commands: [{ command: `oompf add ${c.args.ref}` }],
8265
8771
  description: "Install it with:"
8266
8772
  } });
8267
8773
  }
@@ -8272,7 +8778,6 @@ function registerInspect(cli, deps) {
8272
8778
  aliases: facts ? [...facts.aliases] : [],
8273
8779
  errors: [...validation.errors],
8274
8780
  hash: gist.contentHash,
8275
- installCommand: `oompf add ${c.args.ref}`,
8276
8781
  metadata: {
8277
8782
  ...validation.metadata,
8278
8783
  links: [...validation.metadata.links],
@@ -8288,10 +8793,7 @@ function registerInspect(cli, deps) {
8288
8793
  sourceType: "gist",
8289
8794
  structural: validation.structural,
8290
8795
  warnings: [...validation.warnings]
8291
- }, { cta: {
8292
- commands: [`oompf add ${c.args.ref}`],
8293
- description: "Install it with:"
8294
- } });
8796
+ });
8295
8797
  } catch (error) {
8296
8798
  return toCliError(c.error, error);
8297
8799
  }
@@ -8312,37 +8814,57 @@ function registerInspect(cli, deps) {
8312
8814
  * the single selected config artifact is sent, and high-confidence secrets
8313
8815
  * abort the publish before anything leaves the machine.
8314
8816
  */
8817
+ /** Narrow a discovered profile to one that carries a publishable config. */
8818
+ function hasConfig(profile) {
8819
+ return profile.configPath !== null;
8820
+ }
8315
8821
  /** Concatenate the base URL and a site-relative path from the register call. */
8316
- function toOompfUrl(baseUrl, path) {
8822
+ function toOompfUrl$1(baseUrl, path) {
8317
8823
  return `${baseUrl.replace(/\/+$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
8318
8824
  }
8319
8825
  /** Register the `publish` command on the given CLI. */
8320
8826
  function registerPublish(cli, deps) {
8321
8827
  cli.command("publish", {
8322
- args: z.object({ profile: z.string().optional().describe("Local OMP profile name; omitted picks the sole profile") }),
8828
+ args: z.object({ profile: z.string().optional().describe("Native OMP profile name; omitted selects a publishable profile") }),
8323
8829
  description: "Publish a local OMP profile as a public Gist and index it",
8324
8830
  env: cliEnv,
8325
8831
  examples: [{
8326
8832
  args: { profile: "work" },
8327
8833
  description: "Publish the 'work' profile"
8328
8834
  }],
8835
+ options: z.object({ agent: z.enum(["omp", "pi"]).optional().describe("Agent runtime to use (default: omp)") }),
8329
8836
  output: publishOutput,
8330
8837
  async run(c) {
8331
8838
  try {
8332
- const ompOptions = { ompCommand: deps.ompCommand };
8839
+ const ompOptions = { ompCommand: deps.ompCommand ?? (await deps.resolveAgentRuntime({ requested: c.options.agent })).command };
8333
8840
  let name;
8334
8841
  let configPath;
8335
8842
  if (c.args.profile === void 0) {
8336
- const discovered = await deps.discoverProfiles(ompOptions);
8337
- if (discovered.length === 0) throw new CommandError("no_profile", "No OMP profiles found. Pass a profile name: oompf publish <profile>.");
8338
- if (discovered.length > 1) throw new CommandError("ambiguous_profile", `Multiple profiles found (${discovered.map((p) => p.name).join(", ")}). Specify one: oompf publish <profile>.`);
8339
- const only = discovered[0];
8340
- name = only.name;
8341
- configPath = only.configPath;
8843
+ const publishable = (await deps.discoverProfiles(ompOptions)).filter(hasConfig);
8844
+ if (publishable.length === 0) throw new CommandError("no_profile", "No publishable OMP profiles found. Create a profile with config.yml/config.yaml or pass an existing profile name.");
8845
+ let selected = publishable[0];
8846
+ if (publishable.length > 1) {
8847
+ const names = publishable.map((profile) => profile.name);
8848
+ if (c.formatExplicit || !deps.profileSelector.isInteractive()) throw new CommandError("ambiguous_profile", `Multiple publishable profiles found (${names.join(", ")}). Specify one: oompf publish <profile>.`);
8849
+ const selectedName = await deps.profileSelector.selectProfile(names);
8850
+ if (selectedName === null) throw new CommandError("selection_cancelled", "Profile selection was cancelled. Nothing was published.");
8851
+ const picked = publishable.find((profile) => profile.name === selectedName);
8852
+ if (picked === void 0) throw new CommandError("selection_invariant", "The profile selector returned a name that was not offered.");
8853
+ selected = picked;
8854
+ }
8855
+ name = selected.name;
8856
+ configPath = selected.configPath;
8342
8857
  } else {
8343
- const resolved = await deps.resolveProfileConfig(c.args.profile, ompOptions);
8344
- name = resolved.profile;
8345
- configPath = resolved.configPath;
8858
+ const validation = validateProfileName(c.args.profile);
8859
+ if (!validation.ok) throw new CommandError("invalid_profile", validation.reason);
8860
+ try {
8861
+ const resolved = await deps.resolveProfileConfig(validation.value, ompOptions);
8862
+ name = resolved.profile;
8863
+ configPath = resolved.configPath;
8864
+ } catch (error) {
8865
+ if (error instanceof OmpProfileNotFoundError) throw new CommandError("profile_not_found", `OMP profile "${validation.value}" was not found.`);
8866
+ throw error;
8867
+ }
8346
8868
  }
8347
8869
  if (configPath === null) throw new CommandError("missing_config", `Profile "${name}" has no config.yml/config.yaml to publish.`);
8348
8870
  const yaml = await deps.fs.readFile(configPath);
@@ -8362,7 +8884,7 @@ function registerPublish(cli, deps) {
8362
8884
  runner: deps.runner
8363
8885
  });
8364
8886
  const registration = await registerProfile(c.env.OOMPF_BASE_URL, { source: gist.htmlUrl }, deps.httpFetch);
8365
- const oompfUrl = toOompfUrl(c.env.OOMPF_BASE_URL, registration.url);
8887
+ const oompfUrl = toOompfUrl$1(c.env.OOMPF_BASE_URL, registration.url);
8366
8888
  const addCommand = `oompf add ${oompfUrl}`;
8367
8889
  return c.ok({
8368
8890
  addCommand,
@@ -8381,7 +8903,7 @@ function registerPublish(cli, deps) {
8381
8903
  structural: registration.validation.structural,
8382
8904
  warnings: [...validation.warnings, ...registration.validation.warnings]
8383
8905
  }, { cta: {
8384
- commands: [addCommand],
8906
+ commands: [{ command: addCommand }],
8385
8907
  description: "Install it with:"
8386
8908
  } });
8387
8909
  } catch (error) {
@@ -8409,13 +8931,15 @@ function registerSearch(cli, deps) {
8409
8931
  args: { query: "anthropic" },
8410
8932
  description: "Search for a term"
8411
8933
  }],
8934
+ options: z.object({ cursor: z.string().optional().describe("Opaque pagination cursor from a previous search") }),
8412
8935
  output: searchOutput,
8413
8936
  async run(c) {
8414
8937
  try {
8415
8938
  const query = c.args.query ?? "";
8416
- const response = await searchProfiles(c.env.OOMPF_BASE_URL, query, deps.httpFetch);
8939
+ const response = await searchProfiles(c.env.OOMPF_BASE_URL, query, deps.httpFetch, c.options.cursor);
8417
8940
  return c.ok({
8418
8941
  count: response.results.length,
8942
+ nextCursor: response.nextCursor,
8419
8943
  query: response.query,
8420
8944
  results: response.results.map((r) => ({
8421
8945
  id: r.id,
@@ -8437,15 +8961,100 @@ function registerSearch(cli, deps) {
8437
8961
  });
8438
8962
  }
8439
8963
  //#endregion
8964
+ //#region src/commands/upgrade.ts
8965
+ /**
8966
+ * `oompf upgrade <ref>` — review and optionally apply model-only successors.
8967
+ *
8968
+ * The command plans from the current owned Gist head, not the indexed pinned
8969
+ * revision. It patches that same Gist only after confirmation, then registers
8970
+ * the unchanged source URL so the stable OOMPF profile identity survives.
8971
+ */
8972
+ function toOompfUrl(baseUrl, id) {
8973
+ return `${baseUrl.replace(/\/+$/, "")}/p/${id}`;
8974
+ }
8975
+ function currentGistUrl(gistId) {
8976
+ return `https://api.github.com/gists/${gistId}`;
8977
+ }
8978
+ /** Register the `upgrade` command on the given CLI. */
8979
+ function registerUpgrade(cli, deps) {
8980
+ cli.command("upgrade", {
8981
+ args: z.object({ ref: z.string().describe("OOMPF profile URL or stable profile id") }),
8982
+ description: "Review and optionally apply model upgrades to an OOMPF profile",
8983
+ env: cliEnv,
8984
+ examples: [{
8985
+ args: { ref: "https://oompf.run/p/prof_..." },
8986
+ description: "Preview model upgrades for an indexed profile"
8987
+ }],
8988
+ options: z.object({ yes: z.boolean().optional().describe("Apply the reviewed plan without prompting") }),
8989
+ output: upgradeOutput,
8990
+ async run(c) {
8991
+ try {
8992
+ const id = parseOompfRef(c.args.ref);
8993
+ if (id === null) throw new CommandError("invalid_ref", "Upgrade requires an OOMPF profile URL or stable profile id.");
8994
+ const record = await fetchProfileMetadata(c.env.OOMPF_BASE_URL, id, deps.httpFetch);
8995
+ if (record.gistId === null || record.gistId === void 0) throw new CommandError("unverifiable_artifact", "The indexed profile has no source Gist and cannot be upgraded.");
8996
+ const fetchCurrent = () => fetchPublicGist(currentGistUrl(record.gistId), { fetch: deps.gistFetch });
8997
+ const current = await fetchCurrent();
8998
+ if (current.owner === null) throw new CommandError("unowned_gist", "The source Gist has no public owner and cannot be patched.");
8999
+ const plan = proposeUpgrade(current.content, MODEL_CATALOG);
9000
+ const output = {
9001
+ catalogRevision: plan.catalogRevision,
9002
+ changes: [...plan.changes],
9003
+ currentRevision: current.revision ?? record.revision ?? "unknown",
9004
+ oompfUrl: toOompfUrl(c.env.OOMPF_BASE_URL, record.id),
9005
+ unchanged: [...plan.unchanged],
9006
+ updatedRevision: null
9007
+ };
9008
+ const shouldPrompt = c.options.yes !== true && !c.formatExplicit && deps.profileSelector.isInteractive();
9009
+ if (!(c.options.yes || shouldPrompt)) return c.ok(output);
9010
+ if (shouldPrompt) {
9011
+ const answer = await confirm({ message: plan.changes.length === 0 ? "No model changes are proposed. Exit without writing?" : `Apply ${plan.changes.length} model change(s) to the current Gist?` });
9012
+ if (isCancel(answer) || answer !== true) return c.ok(output);
9013
+ }
9014
+ if (plan.changes.length === 0) return c.ok(output);
9015
+ const latest = await fetchCurrent();
9016
+ if (latest.contentHash !== current.contentHash) throw new CommandError("head_changed", "The source Gist changed while the upgrade was being reviewed. Run upgrade again to plan from the new head.");
9017
+ const identity = await getGithubIdentity({
9018
+ ghCommand: deps.ghCommand,
9019
+ runner: deps.runner
9020
+ });
9021
+ if (latest.owner?.toLowerCase() !== identity.login.toLowerCase()) throw new CommandError("unowned_gist", "The authenticated GitHub user does not own the source Gist; refusing to patch it.");
9022
+ const validation = validateArtifact({ yaml: plan.yaml });
9023
+ if (validation.structural === "invalid" || validation.facts === null) throw new CommandError("invalid_artifact", `The upgraded profile is structurally invalid: ${validation.errors.join("; ")}`);
9024
+ if (validation.blocking.length > 0) throw new CommandError("blocking_secrets", "Refusing to patch the Gist: the upgraded profile contains high-confidence secrets.");
9025
+ const updated = await updatePublicProfileGist({
9026
+ content: plan.yaml,
9027
+ filename: latest.filename,
9028
+ gistId: latest.gistId
9029
+ }, {
9030
+ ghCommand: deps.ghCommand,
9031
+ runner: deps.runner
9032
+ });
9033
+ try {
9034
+ await registerProfile(c.env.OOMPF_BASE_URL, { source: record.sourceUrl }, deps.httpFetch);
9035
+ } catch (error) {
9036
+ throw new CommandError("index_update_failed", `The Gist was updated but OOMPF could not refresh its index. Re-register the unchanged source URL after the index recovers; this command will not patch the Gist again. Details: ${error instanceof Error ? error.message : String(error)}`);
9037
+ }
9038
+ return c.ok({
9039
+ ...output,
9040
+ updatedRevision: updated.revision
9041
+ });
9042
+ } catch (error) {
9043
+ return toCliError(c.error, error);
9044
+ }
9045
+ }
9046
+ });
9047
+ }
9048
+ //#endregion
8440
9049
  //#region src/index.ts
8441
9050
  /**
8442
9051
  * OOMPF CLI entrypoint (binary name: `oompf`).
8443
9052
  *
8444
- * Builds the Incur router CLI, wiring the four commands (`publish`, `add`,
8445
- * `inspect`, `search`) against injectable seams. {@link createCli} takes an
8446
- * optional {@link CliDeps} bundle so focused tests can drive every command with
8447
- * fake `gh`/Gist/HTTP/`omp`/filesystem implementations; production omits it and
8448
- * gets the real Bun/Node-backed seams.
9053
+ * Builds the Incur router CLI, wiring the five commands (`publish`, `add`,
9054
+ * `inspect`, `search`, `upgrade`) against injectable seams. {@link createCli}
9055
+ * takes an optional {@link CliDeps} bundle so focused tests can drive every
9056
+ * command with fake `gh`/Gist/HTTP/`omp`/filesystem implementations; production
9057
+ * omits it and gets the real Bun/Node-backed seams.
8449
9058
  */
8450
9059
  /**
8451
9060
  * OOMPF CLI version, surfaced by `oompf --version`.
@@ -8468,6 +9077,7 @@ function createCli(deps = {}) {
8468
9077
  registerAdd(cli, resolved);
8469
9078
  registerInspect(cli, resolved);
8470
9079
  registerSearch(cli, resolved);
9080
+ registerUpgrade(cli, resolved);
8471
9081
  return cli;
8472
9082
  }
8473
9083
  if (import.meta.main) await createCli().serve();