omkit 0.4.8 → 0.8.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +168 -12
  3. package/dist/actions/chrome-page.d.ts +41 -0
  4. package/dist/actions/chrome-page.js +77 -29
  5. package/dist/actions/drive-page.d.ts +50 -0
  6. package/dist/actions/drive-page.js +68 -0
  7. package/dist/actions/index.d.ts +5 -3
  8. package/dist/actions/index.js +2 -1
  9. package/dist/actions/prompt.d.ts +32 -5
  10. package/dist/actions/prompt.js +92 -8
  11. package/dist/cli/commands/help.js +6 -0
  12. package/dist/cli/commands/init.js +1 -1
  13. package/dist/cli/commands/ls.d.ts +11 -4
  14. package/dist/cli/commands/ls.js +35 -15
  15. package/dist/cli/commands/mcp.d.ts +10 -0
  16. package/dist/cli/commands/mcp.js +13 -0
  17. package/dist/cli/commands/skill.d.ts +17 -0
  18. package/dist/cli/commands/skill.js +56 -0
  19. package/dist/cli/index.d.ts +8 -0
  20. package/dist/cli/index.js +71 -29
  21. package/dist/client/discover-child.d.ts +12 -0
  22. package/dist/client/discover-child.js +71 -0
  23. package/dist/client/discovery.d.ts +21 -1
  24. package/dist/client/discovery.js +139 -2
  25. package/dist/client/index.d.ts +2 -1
  26. package/dist/client/index.js +3 -1
  27. package/dist/client/outline.d.ts +35 -0
  28. package/dist/client/outline.js +100 -0
  29. package/dist/client/registry.d.ts +59 -0
  30. package/dist/client/runner.d.ts +12 -0
  31. package/dist/client/runner.js +16 -7
  32. package/dist/client/types.d.ts +9 -1
  33. package/dist/core/ActionRun.d.ts +5 -0
  34. package/dist/core/ActionRun.js +9 -0
  35. package/dist/core/ExecutionTree.d.ts +40 -2
  36. package/dist/core/ExecutionTree.js +66 -2
  37. package/dist/core/action.d.ts +2 -1
  38. package/dist/core/action.js +93 -9
  39. package/dist/core/args.d.ts +50 -0
  40. package/dist/core/args.js +166 -0
  41. package/dist/core/discovery-mode.d.ts +37 -0
  42. package/dist/core/discovery-mode.js +54 -0
  43. package/dist/core/interaction.d.ts +3 -1
  44. package/dist/core/om.d.ts +11 -9
  45. package/dist/core/om.js +182 -13
  46. package/dist/core/schema-hint.d.ts +20 -0
  47. package/dist/core/schema-hint.js +73 -0
  48. package/dist/core/schema-json.d.ts +12 -0
  49. package/dist/core/schema-json.js +12 -0
  50. package/dist/core/types.d.ts +113 -1
  51. package/dist/foundation/LogEntry.d.ts +1 -1
  52. package/dist/foundation/canonicalPath.d.ts +26 -0
  53. package/dist/foundation/canonicalPath.js +34 -0
  54. package/dist/index.d.ts +2 -2
  55. package/dist/index.js +1 -1
  56. package/dist/mcp/action-host.d.ts +1 -0
  57. package/dist/mcp/action-host.js +65 -0
  58. package/dist/mcp/action-identity.d.ts +23 -0
  59. package/dist/mcp/action-identity.js +35 -0
  60. package/dist/mcp/progress.d.ts +61 -0
  61. package/dist/mcp/progress.js +92 -0
  62. package/dist/mcp/resources.d.ts +55 -0
  63. package/dist/mcp/resources.js +208 -0
  64. package/dist/mcp/runs.d.ts +37 -0
  65. package/dist/mcp/runs.js +60 -0
  66. package/dist/mcp/server.d.ts +19 -0
  67. package/dist/mcp/server.js +38 -0
  68. package/dist/mcp/tail.d.ts +37 -0
  69. package/dist/mcp/tail.js +64 -0
  70. package/dist/mcp/tools.d.ts +54 -0
  71. package/dist/mcp/tools.js +374 -0
  72. package/dist/mcp/validate.d.ts +14 -0
  73. package/dist/mcp/validate.js +54 -0
  74. package/dist/mcp/verdict.d.ts +31 -0
  75. package/dist/mcp/verdict.js +47 -0
  76. package/dist/output/artifact/ArtifactStore.d.ts +24 -0
  77. package/dist/output/artifact/ArtifactStore.js +49 -0
  78. package/dist/output/folder/RunFolder.d.ts +23 -1
  79. package/dist/output/folder/RunFolder.js +55 -11
  80. package/dist/output/writers/views.d.ts +10 -0
  81. package/dist/skill/file.d.ts +26 -0
  82. package/dist/skill/file.js +58 -0
  83. package/dist/skill/index.d.ts +11 -0
  84. package/dist/skill/index.js +9 -0
  85. package/dist/skill/model.d.ts +55 -0
  86. package/dist/skill/model.js +116 -0
  87. package/dist/skill/render.d.ts +18 -0
  88. package/dist/skill/render.js +133 -0
  89. package/package.json +5 -2
@@ -2,6 +2,7 @@ import { type EventHandler } from "../foundation/events.ts";
2
2
  import type { ActionRef } from "../foundation/ActionRef.ts";
3
3
  import type { LogStore } from "../output/log/LogStore.ts";
4
4
  import type { SnapshotStore } from "../output/snapshot/SnapshotStore.ts";
5
+ import type { ArtifactStore } from "../output/artifact/ArtifactStore.ts";
5
6
  import type { Activity, Exec, InstanceEvents, NodeStatus } from "./types.ts";
6
7
  /** Everything a node needs to exist, minus its behavior. */
7
8
  export interface NodeInit {
@@ -19,6 +20,8 @@ export interface NodeInit {
19
20
  readonly parentSignal: AbortSignal | null;
20
21
  /** The run's snapshot store — backs `ctx.snapshot`. */
21
22
  readonly snapshots: SnapshotStore;
23
+ /** The run's artifact registry — backs `ctx.artifact`. */
24
+ readonly artifacts: ArtifactStore;
22
25
  /** Absolute path to the run's output folder — backs `ctx.artifactsFolder`. */
23
26
  readonly artifactsFolder: string;
24
27
  /** Report an assertion outcome to the run verdict. */
@@ -54,6 +57,7 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
54
57
  duration: number;
55
58
  private readonly store;
56
59
  private readonly snapshots;
60
+ private readonly artifacts;
57
61
  private readonly artifactsFolder;
58
62
  private readonly onAssert;
59
63
  private readonly onUnhandledFailure;
@@ -101,6 +105,7 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
101
105
  private context;
102
106
  private assertInvariant;
103
107
  private captureSnapshot;
108
+ private registerArtifact;
104
109
  private attach;
105
110
  private succeed;
106
111
  private fail;
@@ -30,6 +30,7 @@ export class ActionRun {
30
30
  duration = 0;
31
31
  store;
32
32
  snapshots;
33
+ artifacts;
33
34
  artifactsFolder;
34
35
  onAssert;
35
36
  onUnhandledFailure;
@@ -59,6 +60,7 @@ export class ActionRun {
59
60
  this.parentId = init.parentId;
60
61
  this.store = init.store;
61
62
  this.snapshots = init.snapshots;
63
+ this.artifacts = init.artifacts;
62
64
  this.artifactsFolder = init.artifactsFolder;
63
65
  this.onAssert = init.onAssert;
64
66
  this.onUnhandledFailure = init.onUnhandledFailure;
@@ -228,6 +230,7 @@ export class ActionRun {
228
230
  tag: (name) => void this.tag(name),
229
231
  assert: (condition, message) => this.assertInvariant(condition, message),
230
232
  snapshot: (name, value) => this.captureSnapshot(name, value),
233
+ artifact: (name, file, opts) => this.registerArtifact(name, file, opts),
231
234
  artifactsFolder: this.artifactsFolder,
232
235
  proc: createProc((source, level, message) => this.store.append({ nodeId: this.id, path: this.path, level, source, message }), this.signal),
233
236
  };
@@ -244,6 +247,12 @@ export class ActionRun {
244
247
  this.bubble(`📸 ${name} → ${file}`);
245
248
  return file;
246
249
  }
250
+ registerArtifact(name, file, opts) {
251
+ const record = this.artifacts.register(name, file, this.id, opts);
252
+ this.log("artifact", "artifact", `${name} → ${record.file}`);
253
+ this.bubble(`📎 ${name} → ${record.file}`);
254
+ return record.file;
255
+ }
247
256
  attach(handle) {
248
257
  if (this.attached)
249
258
  return;
@@ -1,8 +1,10 @@
1
1
  import { LogStore } from "../output/log/LogStore.ts";
2
2
  import { RunFolder } from "../output/folder/RunFolder.ts";
3
+ import { type ArtifactRecord } from "../output/artifact/ArtifactStore.ts";
3
4
  import type { RunView } from "../output/writers/views.ts";
4
5
  import { ActionRun } from "./ActionRun.ts";
5
- import type { Exec, LaunchSpec } from "./types.ts";
6
+ import type { Exec, LaunchSpec, OmDescription, ResolvedMcpExposure } from "./types.ts";
7
+ import type { LogEntry } from "../foundation/LogEntry.ts";
6
8
  /**
7
9
  * The run: a module singleton that owns the log store, the run folder, the node
8
10
  * registry, and the process-wide teardown. `launch` creates a child node under the
@@ -20,9 +22,22 @@ export declare class ExecutionTree {
20
22
  readonly store: LogStore;
21
23
  readonly folder: RunFolder;
22
24
  readonly root: ActionRun;
25
+ /**
26
+ * The om's `.describe(…)` summary, carried here from the builder so it survives the
27
+ * terminal `.run(...)` call. Stored so it is retrievable; nothing reads it yet.
28
+ */
29
+ readonly description: OmDescription | undefined;
30
+ /**
31
+ * The om's `.mcp(…)` exposure, carried here from the builder for the same reason
32
+ * `description` is: `.run(...)` is the terminal call, and an om has no definition object
33
+ * to hang it on. The MCP server reads its own copy from the discovery fork, not from
34
+ * here — this is what makes the carry observable, and therefore testable, at all.
35
+ */
36
+ readonly mcp: ResolvedMcpExposure | undefined;
23
37
  private readonly rawStream;
24
38
  private readonly consoleCapture;
25
39
  private readonly snapshotStore;
40
+ private readonly artifactStore;
26
41
  private readonly registry;
27
42
  private readonly unsettled;
28
43
  private readonly originalLog;
@@ -31,6 +46,7 @@ export declare class ExecutionTree {
31
46
  private forwarding;
32
47
  private assertionsPassed;
33
48
  private assertionsFailed;
49
+ private rootArgs;
34
50
  private readonly faults;
35
51
  static graceMs: number;
36
52
  private phase;
@@ -40,7 +56,9 @@ export declare class ExecutionTree {
40
56
  private readonly onSigint;
41
57
  private readonly onUncaught;
42
58
  private readonly onUnhandled;
43
- constructor(name: string, definedAt?: string);
59
+ constructor(name: string, definedAt?: string, description?: OmDescription, mcp?: ResolvedMcpExposure);
60
+ /** Record the run's resolved args, so the root's log header reports them. */
61
+ setRootArgs(args: unknown): void;
44
62
  /** Launch an instance under the ambient current node (or the root). */
45
63
  launch(spec: LaunchSpec): ActionRun;
46
64
  private append;
@@ -83,7 +101,27 @@ export declare class ExecutionTree {
83
101
  private nodeView;
84
102
  /** Test seam: the projected run view (same object written to result.json). */
85
103
  runViewForTest(): RunView;
104
+ /** Test seam: the run's curated artifacts. */
105
+ artifactsForTest(): readonly ArtifactRecord[];
106
+ /** Test seam: every log entry recorded for this run. */
107
+ entriesForTest(): readonly LogEntry[];
86
108
  private runView;
109
+ /**
110
+ * The run's curated artifacts, keyed by `(nodeId, name)`: one action re-registering
111
+ * a name (e.g. re-labelling the same file after regenerating it) updates that entry
112
+ * in place rather than appending a second one, so `result.json` always reflects the
113
+ * latest registration per name. Keying on the pair — not `name` alone — matters
114
+ * because two *different* actions can independently choose the same name (two
115
+ * screenshot steps both calling `ctx.artifact("screenshot", …)`); those describe two
116
+ * distinct files and both must survive into `result.json`, not collapse into one.
117
+ * Position follows first registration; `artifacts.log` (a chronological rollup, like
118
+ * `events.log`/`asserts.log`) is unaffected either way and still lists every call.
119
+ *
120
+ * The pair is joined with `\0` — the same separator `omHash` uses, and for the same
121
+ * reason: a step name may legitimately contain colons (`TSKB:root:watch:docs`) and an
122
+ * artifact name is free-form, so any printable separator can be forged inside a key.
123
+ */
124
+ private curatedArtifacts;
87
125
  /**
88
126
  * Absolute `.log` path for a node — root is `main.log`, children drop the `main/`
89
127
  * prefix. Each id segment is sanitized so names with filesystem-illegal characters
@@ -7,6 +7,7 @@ import { LogStore } from "../output/log/LogStore.js";
7
7
  import { RunFolder } from "../output/folder/RunFolder.js";
8
8
  import { RawStream } from "../output/log/RawStream.js";
9
9
  import { SnapshotStore } from "../output/snapshot/SnapshotStore.js";
10
+ import { ArtifactStore } from "../output/artifact/ArtifactStore.js";
10
11
  import { ConsoleCapture } from "../output/console/ConsoleCapture.js";
11
12
  import { LiveRenderer } from "../output/LiveRenderer.js";
12
13
  import { writeNodeLogs } from "../output/writers/NodeLogWriter.js";
@@ -40,9 +41,22 @@ export class ExecutionTree {
40
41
  store = new LogStore();
41
42
  folder;
42
43
  root;
44
+ /**
45
+ * The om's `.describe(…)` summary, carried here from the builder so it survives the
46
+ * terminal `.run(...)` call. Stored so it is retrievable; nothing reads it yet.
47
+ */
48
+ description;
49
+ /**
50
+ * The om's `.mcp(…)` exposure, carried here from the builder for the same reason
51
+ * `description` is: `.run(...)` is the terminal call, and an om has no definition object
52
+ * to hang it on. The MCP server reads its own copy from the discovery fork, not from
53
+ * here — this is what makes the carry observable, and therefore testable, at all.
54
+ */
55
+ mcp;
43
56
  rawStream;
44
57
  consoleCapture;
45
58
  snapshotStore;
59
+ artifactStore = new ArtifactStore();
46
60
  registry = [];
47
61
  unsettled = new Set();
48
62
  originalLog = console.log.bind(console);
@@ -51,6 +65,10 @@ export class ExecutionTree {
51
65
  forwarding;
52
66
  assertionsPassed = 0;
53
67
  assertionsFailed = 0;
68
+ // The root's args are resolved *inside* its body (prompting is async), so unlike a
69
+ // child node's they cannot be handed to `run()` up front — they are recorded here
70
+ // instead and projected onto the root by `nodeView`.
71
+ rootArgs = [];
54
72
  faults = [];
55
73
  static graceMs = 5000; // teardown waits this long for nodes to settle, then finalizes anyway
56
74
  phase = "open";
@@ -65,7 +83,9 @@ export class ExecutionTree {
65
83
  // Process hooks — installed at run start, removed at finalize (they must not outlive the run).
66
84
  onUncaught = (e) => this.onFatal("uncaughtException", e);
67
85
  onUnhandled = (e) => this.onFatal("unhandledRejection", e);
68
- constructor(name, definedAt) {
86
+ constructor(name, definedAt, description, mcp) {
87
+ this.description = description;
88
+ this.mcp = mcp;
69
89
  // The run's identity: its name + where om() is written (not how it was launched),
70
90
  // so same-named oms in different files get distinct log folders.
71
91
  this.folder = new RunFolder(name, omHash(name, siteFile(definedAt)));
@@ -87,6 +107,10 @@ export class ExecutionTree {
87
107
  this.consoleCapture = new ConsoleCapture(this.store, () => (currentNode.getStore() ?? this.root).toRef());
88
108
  ExecutionTree.last = this;
89
109
  }
110
+ /** Record the run's resolved args, so the root's log header reports them. */
111
+ setRootArgs(args) {
112
+ this.rootArgs = [args];
113
+ }
90
114
  /** Launch an instance under the ambient current node (or the root). */
91
115
  launch(spec) {
92
116
  if (this.phase !== "open") {
@@ -137,6 +161,7 @@ export class ExecutionTree {
137
161
  return {
138
162
  store: this.store,
139
163
  snapshots: this.snapshotStore,
164
+ artifacts: this.artifactStore,
140
165
  artifactsFolder: this.folder.path(),
141
166
  onAssert: (pass, actionPath, message) => this.recordAssert(pass, actionPath, message),
142
167
  onUnhandledFailure: (actionPath, error) => {
@@ -276,6 +301,7 @@ export class ExecutionTree {
276
301
  await writeRollup(at("events.log"), flat, entries, (e) => e.level === "event");
277
302
  await writeRollup(at("asserts.log"), flat, entries, (e) => e.level === "assert", assertSummary);
278
303
  await writeRollup(at("snapshots.log"), flat, entries, (e) => e.level === "snapshot");
304
+ await writeRollup(at("artifacts.log"), flat, entries, (e) => e.level === "artifact");
279
305
  await writeResult(at("result.json"), this.runView());
280
306
  // Only now let go of the process hooks — a Ctrl+C during the writes must still be
281
307
  // caught (as a no-op) so it can't fall through to Node's kill-without-log default.
@@ -306,6 +332,7 @@ export class ExecutionTree {
306
332
  ` events → ${at("events.log")}`,
307
333
  ` asserts → ${at("asserts.log")} ${assertSummary}`,
308
334
  ` snapshots → ${at("snapshots.log")}`,
335
+ ` artifacts → ${at("artifacts.log")}`,
309
336
  ];
310
337
  }
311
338
  /** The recap to the terminal (files are already written). */
@@ -327,7 +354,8 @@ export class ExecutionTree {
327
354
  path: node.path,
328
355
  parentId: node.parentId,
329
356
  tags: [...node.tags],
330
- args: [...node.args],
357
+ // The root never has args passed to `run()`; its own are resolved mid-body.
358
+ args: [...(node.parentId === null ? this.rootArgs : node.args)],
331
359
  status: node.status,
332
360
  startedAt: node.startedAt,
333
361
  endedAt: node.endedAt,
@@ -340,6 +368,14 @@ export class ExecutionTree {
340
368
  runViewForTest() {
341
369
  return this.runView();
342
370
  }
371
+ /** Test seam: the run's curated artifacts. */
372
+ artifactsForTest() {
373
+ return this.artifactStore.all();
374
+ }
375
+ /** Test seam: every log entry recorded for this run. */
376
+ entriesForTest() {
377
+ return this.store.entries();
378
+ }
343
379
  runView() {
344
380
  return {
345
381
  ok: this.faults.length === 0,
@@ -350,8 +386,36 @@ export class ExecutionTree {
350
386
  duration: this.root.duration,
351
387
  rawStream: this.rawStreamPath(),
352
388
  root: this.nodeView(this.root),
389
+ artifacts: this.curatedArtifacts(),
353
390
  };
354
391
  }
392
+ /**
393
+ * The run's curated artifacts, keyed by `(nodeId, name)`: one action re-registering
394
+ * a name (e.g. re-labelling the same file after regenerating it) updates that entry
395
+ * in place rather than appending a second one, so `result.json` always reflects the
396
+ * latest registration per name. Keying on the pair — not `name` alone — matters
397
+ * because two *different* actions can independently choose the same name (two
398
+ * screenshot steps both calling `ctx.artifact("screenshot", …)`); those describe two
399
+ * distinct files and both must survive into `result.json`, not collapse into one.
400
+ * Position follows first registration; `artifacts.log` (a chronological rollup, like
401
+ * `events.log`/`asserts.log`) is unaffected either way and still lists every call.
402
+ *
403
+ * The pair is joined with `\0` — the same separator `omHash` uses, and for the same
404
+ * reason: a step name may legitimately contain colons (`TSKB:root:watch:docs`) and an
405
+ * artifact name is free-form, so any printable separator can be forged inside a key.
406
+ */
407
+ curatedArtifacts() {
408
+ const byKey = new Map();
409
+ for (const { name, file, description, mime, nodeId } of this.artifactStore.all()) {
410
+ byKey.set(`${nodeId}\0${name}`, {
411
+ name,
412
+ file,
413
+ mime,
414
+ ...(description === undefined ? {} : { description }),
415
+ });
416
+ }
417
+ return [...byKey.values()];
418
+ }
355
419
  /**
356
420
  * Absolute `.log` path for a node — root is `main.log`, children drop the `main/`
357
421
  * prefix. Each id segment is sanitized so names with filesystem-illegal characters
@@ -1,7 +1,8 @@
1
1
  import type { ActionBuilderEvents, NoEvents } from "./types.ts";
2
2
  /**
3
3
  * Define an action. Provide the impl via `.run(…)`, optionally after declaring
4
- * events (`.emits<…>()`) and/or an imperative handle (`.ref<…>()`). Call the result
4
+ * metadata (`.describe(…)`), events (`.emits<…>()`), an imperative handle
5
+ * (`.ref<…>()`), and/or pinning the args type (`.args(schema)`). Call the result
5
6
  * to launch it.
6
7
  */
7
8
  export declare function action(name: string): ActionBuilderEvents<NoEvents, void>;
@@ -1,36 +1,120 @@
1
1
  import { ExecutionTree } from "./ExecutionTree.js";
2
2
  import { callerSite } from "../foundation/callsite.js";
3
+ import { describeSchema } from "./discovery-mode.js";
3
4
  /**
4
- * The builder returned by `action(name)`: declare events (`.emits<…>()`) and/or an
5
- * imperative handle (`.ref<…>()`), then bind the impl with `.run(…)`. `.run` returns a
6
- * callable {@link Action} — **invoking it launches** the action under the ambient current
7
- * node and returns the live {@link Activity}, on which `withCache`/`tag`/… chain.
5
+ * The builder returned by `action(name)`: declare metadata (`.describe(…)`), events
6
+ * (`.emits<…>()`), an imperative handle (`.ref<…>()`), and/or pin the args type
7
+ * (`.args(schema)`), then bind the impl with `.run(…)`. `.run` returns a callable
8
+ * {@link Action} — **invoking it launches** the action under the ambient current node
9
+ * and returns the live {@link Activity}, on which `withCache`/`tag`/… chain.
8
10
  */
9
11
  class Builder {
10
12
  name;
11
- constructor(name) {
13
+ description;
14
+ exposure;
15
+ constructor(name,
16
+ /** Carried across `.emits()`/`.ref()`, which otherwise rebuild the builder. */
17
+ description = undefined,
18
+ /** Carried the same way, for the same reason. */
19
+ exposure = undefined) {
12
20
  this.name = name;
21
+ this.description = description;
22
+ this.exposure = exposure;
23
+ }
24
+ describe(description) {
25
+ this.description = description;
26
+ return this;
27
+ }
28
+ mcp(exposure = {}) {
29
+ // The default is filled here, not at the reader, so every consumer sees one shape.
30
+ this.exposure = { mode: exposure.mode ?? "settling" };
31
+ return this;
13
32
  }
14
33
  emits() {
15
- return new Builder(this.name);
34
+ return new Builder(this.name, this.description, this.exposure);
16
35
  }
17
36
  ref() {
18
- return new Builder(this.name);
37
+ return new Builder(this.name, this.description, this.exposure);
38
+ }
39
+ args(schema) {
40
+ return new ArgsBuilder(this.name, schema, this.description, this.exposure);
19
41
  }
20
42
  run(body) {
21
43
  const name = this.name;
44
+ // Carried onto the definition for `omkit ls` and the MCP server; not read by the runtime.
45
+ const description = this.description;
46
+ const mcp = this.exposure;
22
47
  const definedAt = callerSite(); // the `.run(...)` call site — where this action lives
48
+ // No `.args()` on this path, so there is nothing to describe.
49
+ const describeArgs = () => describeSchema(undefined);
50
+ const exec = body;
51
+ const create = (...args) => {
52
+ const spec = { name, args, tags: [], body: exec, definedAt };
53
+ return ExecutionTree.require().launch(spec);
54
+ };
55
+ return Object.assign(create, {
56
+ actionName: name,
57
+ definedAt,
58
+ description,
59
+ mcp,
60
+ describeArgs,
61
+ });
62
+ }
63
+ }
64
+ /**
65
+ * The builder after `.args(schema)`. The schema pins `.run`'s parameter type; it is not
66
+ * validated here — an action invoked from an om body is passed its args directly in code.
67
+ * (Spec B's MCP server validates before it ever reaches this point.)
68
+ */
69
+ class ArgsBuilder {
70
+ name;
71
+ schema;
72
+ description;
73
+ exposure;
74
+ constructor(name,
75
+ /** Type-level only: `S` pins `.run`'s parameter. Nothing reads the value at runtime. */
76
+ schema, description, exposure) {
77
+ this.name = name;
78
+ this.schema = schema;
79
+ this.description = description;
80
+ this.exposure = exposure;
81
+ }
82
+ describe(description) {
83
+ this.description = description;
84
+ return this;
85
+ }
86
+ mcp(exposure = {}) {
87
+ this.exposure = { mode: exposure.mode ?? "settling" };
88
+ return this;
89
+ }
90
+ run(body) {
91
+ const name = this.name;
92
+ // Carried onto the definition for `omkit ls` and the MCP server; not read by the runtime.
93
+ const description = this.description;
94
+ const mcp = this.exposure;
95
+ const definedAt = callerSite();
96
+ // The schema is captured so the conversion happens in *this* copy of omkit — the one
97
+ // that built it — rather than in whichever copy the discovery child is running.
98
+ const schema = this.schema;
99
+ const describeArgs = () => describeSchema(schema);
23
100
  const exec = body;
24
101
  const create = (...args) => {
25
102
  const spec = { name, args, tags: [], body: exec, definedAt };
26
103
  return ExecutionTree.require().launch(spec);
27
104
  };
28
- return Object.assign(create, { actionName: name, definedAt });
105
+ return Object.assign(create, {
106
+ actionName: name,
107
+ definedAt,
108
+ description,
109
+ mcp,
110
+ describeArgs,
111
+ });
29
112
  }
30
113
  }
31
114
  /**
32
115
  * Define an action. Provide the impl via `.run(…)`, optionally after declaring
33
- * events (`.emits<…>()`) and/or an imperative handle (`.ref<…>()`). Call the result
116
+ * metadata (`.describe(…)`), events (`.emits<…>()`), an imperative handle
117
+ * (`.ref<…>()`), and/or pinning the args type (`.args(schema)`). Call the result
34
118
  * to launch it.
35
119
  */
36
120
  export function action(name) {
@@ -0,0 +1,50 @@
1
+ import type { z } from "zod";
2
+ import { type JsonSchema } from "./schema-json.ts";
3
+ /** Thrown when args cannot be resolved and nobody can be asked. */
4
+ export declare class MissingArgsError extends Error {
5
+ readonly fields: string[];
6
+ /**
7
+ * @param fields Top-level field names that blocked resolution.
8
+ * @param detail Why, when at least one of them *was* supplied — a value that is present
9
+ * but wrong is not missing, and must not be reported as though it were absent.
10
+ */
11
+ constructor(fields: string[], detail?: string);
12
+ }
13
+ /** What the caller must render to get one value from the user. */
14
+ export interface AskSpec {
15
+ /** Dotted path of the field, e.g. `rows` or `config`. */
16
+ readonly path: string;
17
+ readonly kind: "scalar" | "json";
18
+ /** One-line type sketch for the prompt, or `undefined` when the shape cannot be sketched. */
19
+ readonly hint: string | undefined;
20
+ /** The field's JSON Schema, for printing when there is no hint. */
21
+ readonly jsonSchema: JsonSchema;
22
+ }
23
+ export interface ResolveOptions {
24
+ /** Values already supplied (from `OMKIT_ARGS`). */
25
+ readonly supplied: unknown;
26
+ /** False when there is no supervisor and stdin is not a TTY. */
27
+ readonly interactive: boolean;
28
+ readonly ask: (spec: AskSpec) => Promise<string>;
29
+ }
30
+ /**
31
+ * Fill a schema's shape from supplied values, then defaults, then prompting — and fail if
32
+ * that is not enough. Defaults need no work of their own: `safeParse` applies them, so a
33
+ * defaulted field is never blocking and is never asked about.
34
+ *
35
+ * Interactivity is checked **before any prompting**: if nobody can be asked, every blocking
36
+ * field is collected into a single error rather than asking for one and then discovering
37
+ * the second is unanswerable.
38
+ */
39
+ export declare function resolveArgs(schema: z.ZodType, opts: ResolveOptions): Promise<unknown>;
40
+ /**
41
+ * True once a JSON-kind answer is finished. A `{`/`[` opener is a pasted blob and is done
42
+ * when it parses; anything else is a path to a JSON file, and one line is the whole answer.
43
+ *
44
+ * Exported because the prompt that *collects* the answer has to stop on exactly the inputs
45
+ * {@link coerce} knows how to read — `om.ts` passes this as the multiline prompt's `until`.
46
+ * Let the two rules drift apart and the documented file-path answer becomes untypable: a
47
+ * bare path is not JSON, so a JSON-only terminator keeps reading, and a real terminal never
48
+ * reaches the EOF that would otherwise end it.
49
+ */
50
+ export declare function jsonAnswerComplete(text: string): boolean;
@@ -0,0 +1,166 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { toJsonSchema } from "./schema-json.js";
3
+ import { shapeHint } from "./schema-hint.js";
4
+ /** Thrown when args cannot be resolved and nobody can be asked. */
5
+ export class MissingArgsError extends Error {
6
+ fields;
7
+ /**
8
+ * @param fields Top-level field names that blocked resolution.
9
+ * @param detail Why, when at least one of them *was* supplied — a value that is present
10
+ * but wrong is not missing, and must not be reported as though it were absent.
11
+ */
12
+ constructor(fields, detail) {
13
+ super(`could not resolve arg${fields.length === 1 ? "" : "s"}: ${fields.join(", ")} — ` +
14
+ `pass them via OMKIT_ARGS, or run interactively` +
15
+ (detail ? ` (${detail})` : ""));
16
+ this.fields = fields;
17
+ this.name = "MissingArgsError";
18
+ }
19
+ }
20
+ /** How many rounds of asking before resolution gives up. */
21
+ const MAX_ATTEMPTS = 3;
22
+ /**
23
+ * Fill a schema's shape from supplied values, then defaults, then prompting — and fail if
24
+ * that is not enough. Defaults need no work of their own: `safeParse` applies them, so a
25
+ * defaulted field is never blocking and is never asked about.
26
+ *
27
+ * Interactivity is checked **before any prompting**: if nobody can be asked, every blocking
28
+ * field is collected into a single error rather than asking for one and then discovering
29
+ * the second is unanswerable.
30
+ */
31
+ export async function resolveArgs(schema, opts) {
32
+ // Converted lazily, and only once something actually has to be asked. `toJSONSchema`
33
+ // throws for types it cannot represent (`z.date()`, and anything built on it), and only
34
+ // the prompt needs the conversion — a run whose args all arrived supplied or defaulted
35
+ // must not be failed by work it never required.
36
+ let converted;
37
+ const root = () => (converted ??= toJsonSchema(schema));
38
+ let current = asRecord(opts.supplied);
39
+ for (let attempt = 0;; attempt++) {
40
+ const parsed = schema.safeParse(current);
41
+ if (parsed.success)
42
+ return parsed.data;
43
+ const blocking = blockingFields(parsed.error);
44
+ // Nothing nameable to ask for — the schema rejected the object as a whole (a top-level
45
+ // refinement, or a schema that isn't an object at all). No question would make progress,
46
+ // so fail now with the real reason instead of looping silently to the attempt cap.
47
+ if (blocking.length === 0)
48
+ throw new Error(`could not resolve args: ${message(parsed.error)}`);
49
+ if (!opts.interactive) {
50
+ const anySupplied = blocking.some((field) => current[field] !== undefined);
51
+ throw new MissingArgsError(blocking, anySupplied ? message(parsed.error) : undefined);
52
+ }
53
+ if (attempt >= MAX_ATTEMPTS) {
54
+ throw new Error(`could not resolve args after ${MAX_ATTEMPTS} attempts: ${message(parsed.error)}`);
55
+ }
56
+ for (const field of blocking) {
57
+ const fieldSchema = propertySchema(root(), field);
58
+ const kind = isComplex(fieldSchema) ? "json" : "scalar";
59
+ const answer = await opts.ask({
60
+ path: field,
61
+ kind,
62
+ hint: shapeHint(fieldSchema),
63
+ jsonSchema: fieldSchema,
64
+ });
65
+ // An empty answer is *no answer*, not a value. The prompt battery resolves an empty
66
+ // line (and a timeout) to its default (""), and `Number("")` is 0 — so coercing here
67
+ // would silently turn a bare Enter on a required number into a real zero. Leave the
68
+ // field as it was and let the loop re-ask.
69
+ if (answer.trim() === "")
70
+ continue;
71
+ current = { ...current, [field]: coerce(answer, fieldSchema, kind) };
72
+ }
73
+ }
74
+ }
75
+ function asRecord(value) {
76
+ return typeof value === "object" && value !== null ? { ...value } : {};
77
+ }
78
+ /**
79
+ * The top-level field names the error blames, first-seen order, deduplicated. Both an
80
+ * absent field and a present-but-wrong one block resolution and are asked about the same
81
+ * way, so they are one list — whether a field was supplied is read off `current`, which
82
+ * is the direct observation, rather than inferred from zod's issue shape.
83
+ */
84
+ function blockingFields(error) {
85
+ const names = error.issues.map((i) => String(i.path[0] ?? "")).filter((n) => n !== "");
86
+ return [...new Set(names)];
87
+ }
88
+ function message(error) {
89
+ return error.issues.map((i) => `${i.path.join(".") || "(root)"} — ${i.message}`).join("; ");
90
+ }
91
+ function propertySchema(root, field) {
92
+ const properties = root.properties;
93
+ if (typeof properties === "object" && properties !== null) {
94
+ const found = properties[field];
95
+ if (typeof found === "object" && found !== null)
96
+ return found;
97
+ }
98
+ return {};
99
+ }
100
+ function isComplex(schema) {
101
+ return schema.type === "object" || schema.type === "array";
102
+ }
103
+ /**
104
+ * True once a JSON-kind answer is finished. A `{`/`[` opener is a pasted blob and is done
105
+ * when it parses; anything else is a path to a JSON file, and one line is the whole answer.
106
+ *
107
+ * Exported because the prompt that *collects* the answer has to stop on exactly the inputs
108
+ * {@link coerce} knows how to read — `om.ts` passes this as the multiline prompt's `until`.
109
+ * Let the two rules drift apart and the documented file-path answer becomes untypable: a
110
+ * bare path is not JSON, so a JSON-only terminator keeps reading, and a real terminal never
111
+ * reaches the EOF that would otherwise end it.
112
+ */
113
+ export function jsonAnswerComplete(text) {
114
+ const trimmed = text.trim();
115
+ if (trimmed === "")
116
+ return false; // a bare Enter is not an answer — keep reading
117
+ return isJsonBlob(trimmed) ? parsesAsJson(trimmed) : true;
118
+ }
119
+ /** Whether an answer is a pasted JSON blob rather than a path to one. */
120
+ function isJsonBlob(text) {
121
+ return text.startsWith("{") || text.startsWith("[");
122
+ }
123
+ function parsesAsJson(text) {
124
+ try {
125
+ JSON.parse(text);
126
+ return true;
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
132
+ /**
133
+ * Turn a prompt answer into a value the schema can accept. Scalars coerce from their
134
+ * string form; complex fields parse as JSON, or are read from a file path when the answer
135
+ * does not start with `{` or `[` — pointing at a file beats pasting a large config.
136
+ */
137
+ function coerce(answer, schema, kind) {
138
+ const text = answer.trim();
139
+ if (kind === "json") {
140
+ try {
141
+ return JSON.parse(isJsonBlob(text) ? text : readFileArg(text));
142
+ }
143
+ catch {
144
+ // Unparseable JSON *or* an unreadable path: hand the raw text back so the schema
145
+ // rejects it and the loop re-asks, naming the field. A mistyped filename must not
146
+ // escape as an ENOENT and take the whole run down.
147
+ return text;
148
+ }
149
+ }
150
+ if (schema.type === "number" || schema.type === "integer") {
151
+ const n = Number(text);
152
+ return Number.isNaN(n) ? text : n;
153
+ }
154
+ if (schema.type === "boolean") {
155
+ if (/^(true|yes|y|1)$/i.test(text))
156
+ return true;
157
+ if (/^(false|no|n|0)$/i.test(text))
158
+ return false;
159
+ return text;
160
+ }
161
+ return text;
162
+ }
163
+ /** Read a JSON file named by an answer; a leading `@` is optional sugar. */
164
+ function readFileArg(pathish) {
165
+ return readFileSync(pathish.replace(/^@/, ""), "utf8");
166
+ }