omkit 0.4.8 → 0.8.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/README.md +169 -13
  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 +7 -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 +18 -0
  18. package/dist/cli/commands/skill.js +62 -0
  19. package/dist/cli/index.d.ts +8 -0
  20. package/dist/cli/index.js +78 -30
  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 +15 -5
  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 +14 -1
  33. package/dist/client/types.js +5 -1
  34. package/dist/core/ActionRun.d.ts +5 -0
  35. package/dist/core/ActionRun.js +9 -0
  36. package/dist/core/ExecutionTree.d.ts +40 -2
  37. package/dist/core/ExecutionTree.js +66 -2
  38. package/dist/core/action.d.ts +2 -1
  39. package/dist/core/action.js +93 -9
  40. package/dist/core/args.d.ts +50 -0
  41. package/dist/core/args.js +166 -0
  42. package/dist/core/discovery-mode.d.ts +37 -0
  43. package/dist/core/discovery-mode.js +54 -0
  44. package/dist/core/interaction.d.ts +3 -1
  45. package/dist/core/om.d.ts +11 -9
  46. package/dist/core/om.js +182 -13
  47. package/dist/core/schema-hint.d.ts +20 -0
  48. package/dist/core/schema-hint.js +73 -0
  49. package/dist/core/schema-json.d.ts +12 -0
  50. package/dist/core/schema-json.js +12 -0
  51. package/dist/core/types.d.ts +113 -1
  52. package/dist/foundation/LogEntry.d.ts +1 -1
  53. package/dist/foundation/canonicalPath.d.ts +26 -0
  54. package/dist/foundation/canonicalPath.js +34 -0
  55. package/dist/index.d.ts +2 -2
  56. package/dist/index.js +1 -1
  57. package/dist/mcp/action-host.d.ts +1 -0
  58. package/dist/mcp/action-host.js +65 -0
  59. package/dist/mcp/action-identity.d.ts +23 -0
  60. package/dist/mcp/action-identity.js +35 -0
  61. package/dist/mcp/progress.d.ts +61 -0
  62. package/dist/mcp/progress.js +92 -0
  63. package/dist/mcp/resources.d.ts +55 -0
  64. package/dist/mcp/resources.js +208 -0
  65. package/dist/mcp/runs.d.ts +37 -0
  66. package/dist/mcp/runs.js +60 -0
  67. package/dist/mcp/server.d.ts +19 -0
  68. package/dist/mcp/server.js +38 -0
  69. package/dist/mcp/tail.d.ts +37 -0
  70. package/dist/mcp/tail.js +64 -0
  71. package/dist/mcp/tools.d.ts +54 -0
  72. package/dist/mcp/tools.js +378 -0
  73. package/dist/mcp/validate.d.ts +14 -0
  74. package/dist/mcp/validate.js +54 -0
  75. package/dist/mcp/verdict.d.ts +31 -0
  76. package/dist/mcp/verdict.js +47 -0
  77. package/dist/output/artifact/ArtifactStore.d.ts +24 -0
  78. package/dist/output/artifact/ArtifactStore.js +49 -0
  79. package/dist/output/folder/RunFolder.d.ts +23 -1
  80. package/dist/output/folder/RunFolder.js +70 -11
  81. package/dist/output/writers/views.d.ts +10 -0
  82. package/dist/skill/file.d.ts +26 -0
  83. package/dist/skill/file.js +58 -0
  84. package/dist/skill/index.d.ts +12 -0
  85. package/dist/skill/index.js +10 -0
  86. package/dist/skill/model.d.ts +69 -0
  87. package/dist/skill/model.js +200 -0
  88. package/dist/skill/render.d.ts +18 -0
  89. package/dist/skill/render.js +159 -0
  90. package/dist/skill/root.d.ts +15 -0
  91. package/dist/skill/root.js +27 -0
  92. package/package.json +5 -2
@@ -0,0 +1,50 @@
1
+ import { type ChromePageSource, type PageMatch } from "./chrome-page.ts";
2
+ export interface DrivePageOptions {
3
+ /**
4
+ * Where the page is. Either a **CDP endpoint** (`host:port` or a URL) for a browser
5
+ * running in another process, or a **live handle** — a `Page`, `BrowserContext`, or
6
+ * `Browser` — published by an earlier action in this same run.
7
+ *
8
+ * Both matter, and for different reasons. The address form is plain data, so it can be
9
+ * described by a schema and called from outside the process; the handle form skips the
10
+ * network entirely when the browser is already yours, which is what an om driving its own
11
+ * stack wants. Defaults to the endpoint `browser` opens when given a debugging port.
12
+ */
13
+ target?: ChromePageSource;
14
+ /** Which open tab to drive. Omit to take the first page the browser has open. */
15
+ match?: PageMatch;
16
+ /** Navigate the chosen tab here first. Omit to drive it wherever it already is. */
17
+ url?: string;
18
+ /**
19
+ * The JS to run in the page, as an **expression** — `document.title`, not `return …`.
20
+ * Wrap several statements in an IIFE: `(() => { …; return x })()`. The result travels
21
+ * back through the run's log, so it has to be JSON-serialisable.
22
+ */
23
+ js: string;
24
+ /** Save a PNG of the page afterwards and register it as a run artifact. Default true. */
25
+ screenshot?: boolean;
26
+ }
27
+ /** What {@link drivePage} reports back. */
28
+ export interface DrivePageResult {
29
+ /** Whatever the expression evaluated to. */
30
+ readonly value: unknown;
31
+ readonly title: string;
32
+ readonly url: string;
33
+ /** Absolute path of the screenshot, when one was taken. */
34
+ readonly screenshot?: string;
35
+ }
36
+ /**
37
+ * Evaluate an expression in a live page and report what came back.
38
+ *
39
+ * Reaches its page either way round, and that is the point. Given a **handle** it drives a
40
+ * page an earlier action in the same run already published — the cheap path, and the one an
41
+ * om driving its own stack should take. Given a **CDP address** it attaches to a browser in
42
+ * another process entirely, which is what lets this action be described by a schema and
43
+ * called by name from outside: `browser` and {@link chromePage} publish capability handles,
44
+ * and a handle is not something a caller across a process boundary can pass.
45
+ *
46
+ * Nothing here is owned. `chromePage` closes only a CDP connection it opened, and closing a
47
+ * CDP connection disconnects the client rather than stopping the browser — so this finishing,
48
+ * failing, or being cancelled never tears down the stack it attached to, in either mode.
49
+ */
50
+ export declare const drivePage: import("../core/types.ts").Action<[opts: DrivePageOptions], DrivePageResult, import("../core/types.ts").NoEvents, void>;
@@ -0,0 +1,68 @@
1
+ import path from "node:path";
2
+ import { action } from "../core/action.js";
3
+ import { chromePage } from "./chrome-page.js";
4
+ /**
5
+ * Evaluate an expression in a live page and report what came back.
6
+ *
7
+ * Reaches its page either way round, and that is the point. Given a **handle** it drives a
8
+ * page an earlier action in the same run already published — the cheap path, and the one an
9
+ * om driving its own stack should take. Given a **CDP address** it attaches to a browser in
10
+ * another process entirely, which is what lets this action be described by a schema and
11
+ * called by name from outside: `browser` and {@link chromePage} publish capability handles,
12
+ * and a handle is not something a caller across a process boundary can pass.
13
+ *
14
+ * Nothing here is owned. `chromePage` closes only a CDP connection it opened, and closing a
15
+ * CDP connection disconnects the client rather than stopping the browser — so this finishing,
16
+ * failing, or being cancelled never tears down the stack it attached to, in either mode.
17
+ */
18
+ export const drivePage = action("drivePage")
19
+ .describe({
20
+ summary: "Evaluate JS in a live page — a handle from this run, or a tab of a browser reached over CDP — and report the result.",
21
+ })
22
+ .run(async ({ snapshot, artifact, artifactsFolder }, opts) => {
23
+ const { target = "localhost:9222", match, url } = opts;
24
+ // `chromePage` is daemon-shaped: it stays alive until something stops it, so a run that
25
+ // only awaits its `.ref` never settles — the tree finishes while the process is still
26
+ // held open, and the caller waits out a timeout for a run that is already done. Keep the
27
+ // handle and release it, the same shape `tskb:build` uses for its watcher.
28
+ const attached = chromePage("target", target, {
29
+ ...(url === undefined ? {} : { url }),
30
+ ...(match === undefined ? {} : { match }),
31
+ }).tag("page:attach");
32
+ try {
33
+ const page = await attached.ref;
34
+ return await evaluateIn(page, opts, { snapshot, artifact, artifactsFolder });
35
+ }
36
+ finally {
37
+ // Releases the daemon whether the expression threw or not. Cancelling closes the CDP
38
+ // connection, which disconnects this client and leaves the browser to its owner.
39
+ attached.cancel();
40
+ }
41
+ });
42
+ /** The work itself, split out so the daemon handle above has one obvious release point. */
43
+ async function evaluateIn(page, opts, { snapshot, artifact, artifactsFolder }) {
44
+ const { js, screenshot = true } = opts;
45
+ const title = await page.title();
46
+ void snapshot("page", { title, url: page.url() });
47
+ // Evaluated as an expression in page context. A string is sent verbatim, which is what
48
+ // keeps it clear of the esbuild `__name` rewriting that catches serialized *functions*
49
+ // under tsx — see the shim in chrome-page.ts.
50
+ let value;
51
+ try {
52
+ value = await page.evaluate(js);
53
+ }
54
+ catch (cause) {
55
+ // A page-side stack means little to a caller who only sent a string, so lead with the
56
+ // expression that failed and keep the original as the cause.
57
+ throw new Error(`the expression threw in the page: ${js}`, { cause });
58
+ }
59
+ void snapshot("result", { js, value });
60
+ console.log(`result: ${JSON.stringify(value) ?? "undefined"}`);
61
+ const result = { value, title, url: page.url() };
62
+ if (!screenshot)
63
+ return result;
64
+ const file = path.join(artifactsFolder, "page.png");
65
+ await page.screenshot({ path: file });
66
+ artifact("page.png", file, { description: `The page after evaluating: ${js}` });
67
+ return { ...result, screenshot: file };
68
+ }
@@ -17,8 +17,10 @@ export type { WatchDirOptions, WatchDirEvents } from "./watch-dir.ts";
17
17
  export { tailLog } from "./tail-log.ts";
18
18
  export type { TailLogOptions } from "./tail-log.ts";
19
19
  export { prompt } from "./prompt.ts";
20
- export type { PromptOptions, InputPromptOptions, ChoicePromptOptions, PromptChoice, PromptVia, PromptEvents, } from "./prompt.ts";
21
- export { chromePage } from "./chrome-page.ts";
22
- export type { ChromePageOptions, ChromePageEvents, ChromePageSource, Page, Browser, BrowserContext, } from "./chrome-page.ts";
20
+ export type { PromptOptions, InputPromptOptions, ChoicePromptOptions, MultilinePromptOptions, MultilineUntil, PromptChoice, PromptVia, PromptEvents, } from "./prompt.ts";
21
+ export { chromePage, selectPage, pagesOf } from "./chrome-page.ts";
22
+ export type { ChromePageOptions, ChromePageEvents, ChromePageSource, PageMatch, Page, Browser, BrowserContext, } from "./chrome-page.ts";
23
23
  export { browser } from "./browser.ts";
24
24
  export type { BrowserOptions } from "./browser.ts";
25
+ export { drivePage } from "./drive-page.ts";
26
+ export type { DrivePageOptions, DrivePageResult } from "./drive-page.ts";
@@ -10,5 +10,6 @@ export { watch } from "./watch.js";
10
10
  export { watchDir } from "./watch-dir.js";
11
11
  export { tailLog } from "./tail-log.js";
12
12
  export { prompt } from "./prompt.js";
13
- export { chromePage } from "./chrome-page.js";
13
+ export { chromePage, selectPage, pagesOf } from "./chrome-page.js";
14
14
  export { browser } from "./browser.js";
15
+ export { drivePage } from "./drive-page.js";
@@ -20,7 +20,33 @@ export interface ChoicePromptOptions extends PromptCommon {
20
20
  /** Default selection — a choice value or a 1-based index. Default: the first choice. */
21
21
  default?: string | number;
22
22
  }
23
- export type PromptOptions = InputPromptOptions | ChoicePromptOptions;
23
+ /** How a multiline prompt decides the user is done. */
24
+ export type MultilineUntil = "json" | string | ((text: string) => boolean);
25
+ export interface MultilinePromptOptions extends PromptCommon {
26
+ kind: "multiline";
27
+ /** A one-line type sketch printed under the message. */
28
+ hint?: string;
29
+ /** Default `"json"` — stop once the text parses as JSON. */
30
+ until?: MultilineUntil;
31
+ /** Value used on EOF with no input, or on timeout. Default "". */
32
+ default?: string;
33
+ }
34
+ export type PromptOptions = InputPromptOptions | ChoicePromptOptions | MultilinePromptOptions;
35
+ /** Reads one line, or `undefined` at EOF. */
36
+ export type LineReader = () => Promise<string | undefined>;
37
+ /**
38
+ * Accumulate lines until `until` says stop, or EOF. Exported for testing: keeping the
39
+ * termination rule separate from the terminal means it can be driven by a scripted
40
+ * reader instead of a TTY.
41
+ *
42
+ * `"json"` is self-terminating — pasting a pretty-printed blob just works, with no
43
+ * sentinel to explain. A sentinel line is excluded from the result.
44
+ *
45
+ * Note there is deliberately no Ctrl-D/EOF-by-keystroke terminator: readline treats it as
46
+ * a close, and `prompt` builds and tears down an interface per call, so it risks leaving
47
+ * stdin unusable for later prompts in the same run.
48
+ */
49
+ export declare function readUntil(read: LineReader, until: MultilineUntil): Promise<string>;
24
50
  /** How a prompt's answer was decided. */
25
51
  export type PromptVia = "input" | "default" | "timeout";
26
52
  export interface PromptEvents {
@@ -31,10 +57,11 @@ export interface PromptEvents {
31
57
  };
32
58
  }
33
59
  /**
34
- * Asks the terminal a question — free-text **input** or a **choice** — and resolves
35
- * with the answer, which it also attaches as the handle (`prompt(...).ref` feeds
36
- * another action). A timeout (default 30s) falls back to the default so an
37
- * unattended run never blocks; teardown also unblocks a pending prompt.
60
+ * Asks the terminal a question — free-text **input**, a **choice**, or a **multiline**
61
+ * block (see {@link readUntil} for how it ends) — and resolves with the answer, which it
62
+ * also attaches as the handle (`prompt(...).ref` feeds another action). A timeout
63
+ * (default 30s) falls back to the default so an unattended run never blocks; teardown
64
+ * also unblocks a pending prompt.
38
65
  */
39
66
  export declare const prompt: import("../index.ts").Action<[opts?: PromptOptions | undefined], string, PromptEvents, string>;
40
67
  export {};
@@ -1,15 +1,56 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { action } from "../core/action.js";
3
3
  import { activeSupervisor } from "../core/interaction.js";
4
+ /**
5
+ * Accumulate lines until `until` says stop, or EOF. Exported for testing: keeping the
6
+ * termination rule separate from the terminal means it can be driven by a scripted
7
+ * reader instead of a TTY.
8
+ *
9
+ * `"json"` is self-terminating — pasting a pretty-printed blob just works, with no
10
+ * sentinel to explain. A sentinel line is excluded from the result.
11
+ *
12
+ * Note there is deliberately no Ctrl-D/EOF-by-keystroke terminator: readline treats it as
13
+ * a close, and `prompt` builds and tears down an interface per call, so it risks leaving
14
+ * stdin unusable for later prompts in the same run.
15
+ */
16
+ export async function readUntil(read, until) {
17
+ const lines = [];
18
+ for (;;) {
19
+ const line = await read();
20
+ if (line === undefined)
21
+ break; // EOF
22
+ if (typeof until === "string" && until !== "json" && line === until)
23
+ break;
24
+ lines.push(line);
25
+ const text = lines.join("\n");
26
+ if (until === "json" && parses(text))
27
+ break;
28
+ if (typeof until === "function" && until(text))
29
+ break;
30
+ }
31
+ return lines.join("\n");
32
+ }
33
+ function parses(text) {
34
+ if (text.trim() === "")
35
+ return false;
36
+ try {
37
+ JSON.parse(text);
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
4
44
  const asChoices = (choices) => choices.map((c) => (typeof c === "string" ? { label: c, value: c } : c));
5
45
  /** Route the request over the supervisor channel, resolving the raw answer against the choices. */
6
46
  async function askSupervisor(supervisor, ctx, waitSignal) {
7
47
  const { opts, message, defaultValue, resolveRaw } = ctx;
8
48
  const spec = {
9
- kind: opts.kind === "choice" ? "choice" : "input",
49
+ kind: opts.kind === "choice" ? "choice" : opts.kind === "multiline" ? "multiline" : "input",
10
50
  message,
11
51
  default: defaultValue,
12
52
  ...(opts.kind === "choice" ? { choices: asChoices(opts.choices) } : {}),
53
+ ...(opts.kind === "multiline" && opts.hint ? { hint: opts.hint } : {}),
13
54
  };
14
55
  const answer = await supervisor.request(spec, waitSignal);
15
56
  const resolved = resolveRaw(answer.value);
@@ -18,9 +59,44 @@ async function askSupervisor(supervisor, ctx, waitSignal) {
18
59
  return { value: resolved, via: answer.via ?? "input" };
19
60
  }
20
61
  /** Read the answer straight from the terminal (the bare, unsupervised path). */
21
- async function askTerminal(query, resolveRaw, defaultValue, waitSignal) {
62
+ async function askTerminal(query, resolveRaw, defaultValue, waitSignal, multiline) {
22
63
  const rl = createInterface({ input: process.stdin, output: process.stdout });
23
64
  try {
65
+ if (multiline) {
66
+ process.stdout.write(query);
67
+ // Iterate the interface instead of calling `rl.question` per line. A paste arrives as a
68
+ // single chunk and readline emits every line in it synchronously, so a one-shot `question`
69
+ // takes the first line and the rest are emitted with no listener and lost — which would
70
+ // break the one case this kind exists for. The async iterator queues lines and applies
71
+ // backpressure, so a pasted blob and a typed one read the same.
72
+ const it = rl[Symbol.asyncIterator]();
73
+ // Nothing can abort a pending `it.next()`, so an abort (prompt timeout or run teardown)
74
+ // closes the interface, which ends the iteration. `waitSignal` then tells that apart from
75
+ // a genuinely closed stream below.
76
+ const onAbort = () => void rl.close();
77
+ if (waitSignal.aborted)
78
+ onAbort();
79
+ else
80
+ waitSignal.addEventListener("abort", onAbort, { once: true });
81
+ try {
82
+ const read = async () => {
83
+ const { value, done } = await it.next();
84
+ return done ? undefined : value; // done → end of input
85
+ };
86
+ const text = await readUntil(read, multiline.until);
87
+ // Giving up is not end-of-input. Propagate so the action's outer catch labels the answer
88
+ // `timeout` and falls back to the default, the same as the `input` and `choice` kinds —
89
+ // half a JSON blob is not a useful answer, so the partial text is dropped here.
90
+ if (waitSignal.aborted)
91
+ throw new Error("prompt aborted");
92
+ if (text.trim() === "")
93
+ return { value: defaultValue, via: "default" };
94
+ return { value: text, via: "input" };
95
+ }
96
+ finally {
97
+ waitSignal.removeEventListener("abort", onAbort);
98
+ }
99
+ }
24
100
  const raw = (await rl.question(query, { signal: waitSignal })).trim();
25
101
  if (raw === "")
26
102
  return { value: defaultValue, via: "default" };
@@ -35,10 +111,11 @@ async function askTerminal(query, resolveRaw, defaultValue, waitSignal) {
35
111
  }
36
112
  }
37
113
  /**
38
- * Asks the terminal a question — free-text **input** or a **choice** — and resolves
39
- * with the answer, which it also attaches as the handle (`prompt(...).ref` feeds
40
- * another action). A timeout (default 30s) falls back to the default so an
41
- * unattended run never blocks; teardown also unblocks a pending prompt.
114
+ * Asks the terminal a question — free-text **input**, a **choice**, or a **multiline**
115
+ * block (see {@link readUntil} for how it ends) — and resolves with the answer, which it
116
+ * also attaches as the handle (`prompt(...).ref` feeds another action). A timeout
117
+ * (default 30s) falls back to the default so an unattended run never blocks; teardown
118
+ * also unblocks a pending prompt.
42
119
  */
43
120
  export const prompt = action("prompt")
44
121
  .emits()
@@ -50,7 +127,13 @@ export const prompt = action("prompt")
50
127
  let query;
51
128
  let defaultValue;
52
129
  let resolveRaw;
53
- if (opts.kind === "choice") {
130
+ if (opts.kind === "multiline") {
131
+ defaultValue = opts.default ?? "";
132
+ message = opts.message ?? "Input:";
133
+ query = opts.hint ? `${message}\n ${opts.hint}\n> ` : `${message}\n> `;
134
+ resolveRaw = (raw) => raw;
135
+ }
136
+ else if (opts.kind === "choice") {
54
137
  const choices = asChoices(opts.choices);
55
138
  const { default: def } = opts;
56
139
  const picked = typeof def === "number"
@@ -83,6 +166,7 @@ export const prompt = action("prompt")
83
166
  const waitSignal = AbortSignal.any([signal, onTimeout.signal]);
84
167
  let value = defaultValue;
85
168
  let via = "default";
169
+ const multiline = opts.kind === "multiline" ? { until: opts.until ?? "json" } : undefined;
86
170
  const supervisor = activeSupervisor();
87
171
  try {
88
172
  // Supervised: hand off to whoever owns the terminal (Ink app / MCP client). Bare: read
@@ -90,7 +174,7 @@ export const prompt = action("prompt")
90
174
  // silent supervisor (or nobody) can't wedge the run.
91
175
  const outcome = supervisor
92
176
  ? await askSupervisor(supervisor, { opts, message, defaultValue, resolveRaw }, waitSignal)
93
- : await askTerminal(query, resolveRaw, defaultValue, waitSignal);
177
+ : await askTerminal(query, resolveRaw, defaultValue, waitSignal, multiline);
94
178
  value = outcome.value;
95
179
  via = outcome.via;
96
180
  }
@@ -13,12 +13,19 @@ export function helpText() {
13
13
  " run [om] run an om — interactive picker if no om is given, else by name or file path",
14
14
  " ls list the discovered oms and actions",
15
15
  " check typecheck the project (tsc --noEmit)",
16
+ " skill generate .claude/skills/omkit-runs/SKILL.md from what this project exposes",
17
+ " mcp serve this project over MCP on stdio (for Claude and other assistants)",
16
18
  " init scaffold a starter project (tsconfig.omkit.json + sample oms/ and actions/)",
17
19
  " help show this help",
18
20
  "",
19
21
  "Flags:",
20
22
  " --tsconfig <path> project config to scan (default: tsconfig.omkit.json)",
21
23
  " --json machine-readable output (ls)",
24
+ " --describe import the oms to show their summaries (ls) — slower, runs no workflow",
25
+ " --check report drift and exit non-zero instead of writing (skill)",
26
+ " --out <path> write somewhere else (skill)",
27
+ " --root <path> where the skill is written and what its paths are relative to (skill)",
28
+ " — defaults to the enclosing repository",
22
29
  " --inspect[=port] run the om in a forked child with the Node inspector open (default port 9229)",
23
30
  " -h, --help show this help",
24
31
  "",
@@ -16,7 +16,7 @@ const TSCONFIG = `{
16
16
  const DEV_OM = `import { om } from "omkit";
17
17
  import { command, healthcheck } from "omkit/actions";
18
18
 
19
- om("dev", async () => {
19
+ om("dev").run(async () => {
20
20
  command("npm run dev", { cwd: "api" }).tag("api");
21
21
  const up = await healthcheck({ url: "http://localhost:3000" }).result;
22
22
  if (!up.ok) return;
@@ -1,5 +1,12 @@
1
- import type { Registry } from "../../client/registry.ts";
1
+ import type { RegistrationSet, Registry } from "../../client/registry.ts";
2
+ export interface FormatOptions {
3
+ readonly json?: boolean;
4
+ /**
5
+ * What the oms and actions declare, from the discovery fork. Present only under
6
+ * `--describe`: reading it costs an import of every candidate file, which is why plain
7
+ * `ls` stays AST-only and prints exactly what it always did.
8
+ */
9
+ readonly registrations?: RegistrationSet;
10
+ }
2
11
  /** Render a {@link Registry} for the terminal (plain text) or as JSON. */
3
- export declare function formatRegistry(registry: Registry, opts?: {
4
- json?: boolean;
5
- }): string;
12
+ export declare function formatRegistry(registry: Registry, opts?: FormatOptions): string;
@@ -2,24 +2,44 @@ import path from "node:path";
2
2
  /** Render a {@link Registry} for the terminal (plain text) or as JSON. */
3
3
  export function formatRegistry(registry, opts = {}) {
4
4
  if (opts.json)
5
- return JSON.stringify(registry, null, 2);
6
- const lines = [];
7
- lines.push(`oms (${registry.oms.length})`);
8
- for (const om of registry.oms) {
9
- lines.push(` ${om.name} ${path.basename(om.file)}:${om.line}`);
10
- }
11
- lines.push("");
12
- lines.push(`actions (${registry.actions.length})`);
13
- for (const a of registry.actions) {
14
- const tags = [a.publishesCapability ? "ref" : "", a.events ? "events" : ""].filter(Boolean);
15
- const suffix = tags.length ? ` [${tags.join(", ")}]` : "";
16
- lines.push(` ${a.name} ${path.basename(a.file)}${suffix}`);
17
- }
5
+ return renderJson(registry, opts.registrations);
6
+ const declared = index(opts.registrations);
7
+ const lines = [
8
+ `oms (${registry.oms.length})`,
9
+ ...registry.oms.flatMap((om) => row(`${om.name} ${path.basename(om.file)}:${om.line}`, [], declared?.get(key(om.name, om.file)))),
10
+ "",
11
+ `actions (${registry.actions.length})`,
12
+ ...registry.actions.flatMap((a) => row(`${a.name} ${path.basename(a.file)}`, [a.publishesCapability ? "ref" : "", a.events ? "events" : ""], declared?.get(key(a.name, a.file)))),
13
+ ];
18
14
  if (registry.warnings.length) {
19
- lines.push("");
20
- lines.push(`warnings (${registry.warnings.length})`);
15
+ lines.push("", `warnings (${registry.warnings.length})`);
21
16
  for (const w of registry.warnings)
22
17
  lines.push(` ${w}`);
23
18
  }
24
19
  return lines.join("\n");
25
20
  }
21
+ /** One entry: its head line with any markers, plus an indented summary when one was declared. */
22
+ function row(head, tags, meta) {
23
+ const all = [...tags, ...mcpTags(meta)].filter(Boolean);
24
+ const suffix = all.length ? ` [${all.join(", ")}]` : "";
25
+ return meta?.summary ? [` ${head}${suffix}`, ` ${meta.summary}`] : [` ${head}${suffix}`];
26
+ }
27
+ function renderJson(registry, registrations) {
28
+ return JSON.stringify(registrations ? { ...registry, ...registrations } : registry, null, 2);
29
+ }
30
+ /** Declarations keyed by name-in-file, so a name reused across files cannot cross over. */
31
+ function index(set) {
32
+ if (!set)
33
+ return undefined;
34
+ const byKey = new Map();
35
+ for (const r of [...set.oms, ...set.actions])
36
+ byKey.set(key(r.name, r.file), r);
37
+ return byKey;
38
+ }
39
+ const key = (name, file) => `${name}\0${path.resolve(file)}`;
40
+ /** `[mcp]` / `[mcp, long-lived]` — only meaningful once the fork has told us what is exposed. */
41
+ function mcpTags(meta) {
42
+ if (!meta?.mcp)
43
+ return [];
44
+ return meta.mcp.mode === "long-lived" ? ["mcp", "long-lived"] : ["mcp"];
45
+ }
@@ -0,0 +1,10 @@
1
+ import type { OmkitClient } from "../../client/index.ts";
2
+ /**
3
+ * The `mcp` command: serve this project over MCP on stdio, until the client hangs up.
4
+ *
5
+ * Deliberately silent. Every other command prints; this one must not, because its stdout
6
+ * is the protocol stream — a banner here reaches the client as a JSON parse error.
7
+ */
8
+ export declare function mcpCommand(client: OmkitClient, opts: {
9
+ root: string;
10
+ }): Promise<void>;
@@ -0,0 +1,13 @@
1
+ import { startMcpServer } from "../../mcp/server.js";
2
+ /**
3
+ * The `mcp` command: serve this project over MCP on stdio, until the client hangs up.
4
+ *
5
+ * Deliberately silent. Every other command prints; this one must not, because its stdout
6
+ * is the protocol stream — a banner here reaches the client as a JSON parse error.
7
+ */
8
+ export async function mcpCommand(client, opts) {
9
+ await startMcpServer({ client, root: opts.root });
10
+ // `connect` resolves once the transport is bound; the transport keeps the process alive
11
+ // from here, so hold the command open rather than falling out of `main` and exiting.
12
+ await new Promise(() => { });
13
+ }
@@ -0,0 +1,18 @@
1
+ import type { OmkitClient } from "../../client/index.ts";
2
+ export { skillRoot } from "../../skill/index.ts";
3
+ export interface SkillOptions {
4
+ /** Project root the skill is written relative to — the directory owning the tsconfig. */
5
+ readonly root: string;
6
+ /** Override the output path (still resolved against `root` when relative). */
7
+ readonly out?: string;
8
+ /** Compare hashes and exit non-zero on drift instead of writing. */
9
+ readonly check?: boolean;
10
+ }
11
+ /**
12
+ * The `skill` command: generate `.claude/skills/omkit-runs/SKILL.md` from what this project's
13
+ * oms and actions declare, so an assistant knows what is runnable before its first tool call.
14
+ *
15
+ * Generation runs no workflow. It uses the same `OMKIT_DISCOVER=1` fork the MCP server's
16
+ * `list_oms` uses, which returns from `launch()` before an execution tree exists.
17
+ */
18
+ export declare function skillCommand(client: OmkitClient, opts: SkillOptions): Promise<void>;
@@ -0,0 +1,62 @@
1
+ import path from "node:path";
2
+ import { buildSkillModel, readExisting, renderSkill, writeSkill, DEFAULT_DESCRIPTION, SKILL_RELATIVE_PATH, } from "../../skill/index.js";
3
+ export { skillRoot } from "../../skill/index.js";
4
+ const EMPTY_REGISTRY = { oms: [], actions: [], warnings: [] };
5
+ /**
6
+ * The `skill` command: generate `.claude/skills/omkit-runs/SKILL.md` from what this project's
7
+ * oms and actions declare, so an assistant knows what is runnable before its first tool call.
8
+ *
9
+ * Generation runs no workflow. It uses the same `OMKIT_DISCOVER=1` fork the MCP server's
10
+ * `list_oms` uses, which returns from `launch()` before an execution tree exists.
11
+ */
12
+ export async function skillCommand(client, opts) {
13
+ const file = path.resolve(opts.root, opts.out ?? SKILL_RELATIVE_PATH);
14
+ const registrations = await client.discoverRegistrations();
15
+ // The client's tsconfig is the one discovery actually ran against, so the commands the file
16
+ // renders are the commands that produced it — not a guess at what the reader should type.
17
+ const model = buildSkillModel(registrations, registrations.registry ?? EMPTY_REGISTRY, {
18
+ root: opts.root,
19
+ tsconfig: path.resolve(client.tsconfig),
20
+ });
21
+ const existing = readExisting(file);
22
+ if (opts.check) {
23
+ reportCheck(file, existing.hash, model.hash);
24
+ return;
25
+ }
26
+ // The description is the skill's loading trigger and is authored, so an existing one always
27
+ // wins; the fallback is only ever written into a file that does not exist yet.
28
+ const content = renderSkill(model, { description: existing.description ?? DEFAULT_DESCRIPTION });
29
+ writeSkill(file, content);
30
+ const total = model.oms.length + model.actions.length;
31
+ console.log(`wrote ${path.relative(opts.root, file) || file}`);
32
+ console.log(` ${model.oms.length} om${model.oms.length === 1 ? "" : "s"}, ` +
33
+ `${model.actions.length} action${model.actions.length === 1 ? "" : "s"} ` +
34
+ `[registry-hash: ${model.hash}]`);
35
+ if (total === 0) {
36
+ console.log(" nothing is exposed yet — mark an om or action with .mcp() to list it");
37
+ }
38
+ reportWarnings(registrations.warnings);
39
+ }
40
+ /**
41
+ * `--check` is for a hook or a CI step, so it says what changed and sets the exit code rather
42
+ * than writing. A file with no marker at all counts as drift: it was hand-written or predates
43
+ * the generator, and either way regenerating is the fix.
44
+ */
45
+ function reportCheck(file, existing, fresh) {
46
+ if (existing === fresh) {
47
+ console.log(`up to date [registry-hash: ${fresh}]`);
48
+ return;
49
+ }
50
+ const detail = existing === undefined ? "no registry-hash found" : `has ${existing}`;
51
+ console.error(`${file} is stale — ${detail}, project is ${fresh}`);
52
+ console.error("run `omkit skill` to regenerate");
53
+ process.exitCode = 1;
54
+ }
55
+ /** Discovery degrades to warnings rather than failing, so they have to reach the terminal. */
56
+ function reportWarnings(warnings) {
57
+ if (warnings.length === 0)
58
+ return;
59
+ console.error(`\nwarnings (${warnings.length})`);
60
+ for (const w of warnings)
61
+ console.error(` ${w}`);
62
+ }
@@ -7,6 +7,14 @@ export interface Cli {
7
7
  tsconfig: string;
8
8
  /** Set by `--help`/`-h` (the `help` command is carried on `command` instead). */
9
9
  help: boolean;
10
+ /** `ls --describe`: import the oms to read their summaries, instead of staying AST-only. */
11
+ describe: boolean;
12
+ /** `skill --check`: report drift and exit non-zero instead of writing. */
13
+ check: boolean;
14
+ /** `skill --out <path>`: write somewhere other than `.claude/skills/omkit-runs/SKILL.md`. */
15
+ out?: string;
16
+ /** `skill --root <path>`: override the repository the skill is written for and relative to. */
17
+ root?: string;
10
18
  /** The Node inspector, if requested via `--inspect[=port]`. */
11
19
  inspect?: InspectOptions;
12
20
  }