omkit 0.4.7 → 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 (93) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +170 -13
  3. package/dist/actions/browser.d.ts +1 -1
  4. package/dist/actions/browser.js +1 -1
  5. package/dist/actions/chrome-page.d.ts +46 -4
  6. package/dist/actions/chrome-page.js +81 -33
  7. package/dist/actions/drive-page.d.ts +50 -0
  8. package/dist/actions/drive-page.js +68 -0
  9. package/dist/actions/index.d.ts +7 -3
  10. package/dist/actions/index.js +3 -1
  11. package/dist/actions/port-free.d.ts +29 -0
  12. package/dist/actions/port-free.js +78 -0
  13. package/dist/actions/prompt.d.ts +32 -5
  14. package/dist/actions/prompt.js +92 -8
  15. package/dist/cli/commands/help.js +6 -0
  16. package/dist/cli/commands/init.js +1 -1
  17. package/dist/cli/commands/ls.d.ts +11 -4
  18. package/dist/cli/commands/ls.js +35 -15
  19. package/dist/cli/commands/mcp.d.ts +10 -0
  20. package/dist/cli/commands/mcp.js +13 -0
  21. package/dist/cli/commands/skill.d.ts +17 -0
  22. package/dist/cli/commands/skill.js +56 -0
  23. package/dist/cli/index.d.ts +8 -0
  24. package/dist/cli/index.js +71 -29
  25. package/dist/client/discover-child.d.ts +12 -0
  26. package/dist/client/discover-child.js +71 -0
  27. package/dist/client/discovery.d.ts +21 -1
  28. package/dist/client/discovery.js +139 -2
  29. package/dist/client/index.d.ts +2 -1
  30. package/dist/client/index.js +3 -1
  31. package/dist/client/outline.d.ts +35 -0
  32. package/dist/client/outline.js +100 -0
  33. package/dist/client/registry.d.ts +59 -0
  34. package/dist/client/runner.d.ts +12 -0
  35. package/dist/client/runner.js +16 -7
  36. package/dist/client/types.d.ts +9 -1
  37. package/dist/core/ActionRun.d.ts +5 -0
  38. package/dist/core/ActionRun.js +9 -0
  39. package/dist/core/ExecutionTree.d.ts +40 -2
  40. package/dist/core/ExecutionTree.js +66 -2
  41. package/dist/core/action.d.ts +2 -1
  42. package/dist/core/action.js +93 -9
  43. package/dist/core/args.d.ts +50 -0
  44. package/dist/core/args.js +166 -0
  45. package/dist/core/discovery-mode.d.ts +37 -0
  46. package/dist/core/discovery-mode.js +54 -0
  47. package/dist/core/interaction.d.ts +3 -1
  48. package/dist/core/om.d.ts +11 -9
  49. package/dist/core/om.js +182 -13
  50. package/dist/core/schema-hint.d.ts +20 -0
  51. package/dist/core/schema-hint.js +73 -0
  52. package/dist/core/schema-json.d.ts +12 -0
  53. package/dist/core/schema-json.js +12 -0
  54. package/dist/core/types.d.ts +113 -1
  55. package/dist/foundation/LogEntry.d.ts +1 -1
  56. package/dist/foundation/canonicalPath.d.ts +26 -0
  57. package/dist/foundation/canonicalPath.js +34 -0
  58. package/dist/index.d.ts +2 -2
  59. package/dist/index.js +1 -1
  60. package/dist/mcp/action-host.d.ts +1 -0
  61. package/dist/mcp/action-host.js +65 -0
  62. package/dist/mcp/action-identity.d.ts +23 -0
  63. package/dist/mcp/action-identity.js +35 -0
  64. package/dist/mcp/progress.d.ts +61 -0
  65. package/dist/mcp/progress.js +92 -0
  66. package/dist/mcp/resources.d.ts +55 -0
  67. package/dist/mcp/resources.js +208 -0
  68. package/dist/mcp/runs.d.ts +37 -0
  69. package/dist/mcp/runs.js +60 -0
  70. package/dist/mcp/server.d.ts +19 -0
  71. package/dist/mcp/server.js +38 -0
  72. package/dist/mcp/tail.d.ts +37 -0
  73. package/dist/mcp/tail.js +64 -0
  74. package/dist/mcp/tools.d.ts +54 -0
  75. package/dist/mcp/tools.js +374 -0
  76. package/dist/mcp/validate.d.ts +14 -0
  77. package/dist/mcp/validate.js +54 -0
  78. package/dist/mcp/verdict.d.ts +31 -0
  79. package/dist/mcp/verdict.js +47 -0
  80. package/dist/output/artifact/ArtifactStore.d.ts +24 -0
  81. package/dist/output/artifact/ArtifactStore.js +49 -0
  82. package/dist/output/folder/RunFolder.d.ts +23 -1
  83. package/dist/output/folder/RunFolder.js +55 -11
  84. package/dist/output/writers/views.d.ts +10 -0
  85. package/dist/skill/file.d.ts +26 -0
  86. package/dist/skill/file.js +58 -0
  87. package/dist/skill/index.d.ts +11 -0
  88. package/dist/skill/index.js +9 -0
  89. package/dist/skill/model.d.ts +55 -0
  90. package/dist/skill/model.js +116 -0
  91. package/dist/skill/render.d.ts +18 -0
  92. package/dist/skill/render.js +133 -0
  93. package/package.json +5 -2
@@ -5,11 +5,86 @@ const isPage = (x) => typeof x.mainFrame === "function";
5
5
  const isBrowser = (x) => typeof x.contexts === "function";
6
6
  /** Take the first existing page of a context, or open one. */
7
7
  const pageOf = (context, fresh) => fresh ? context.newPage() : (context.pages()[0] ?? context.newPage());
8
+ /** Every page open across every context — a reattached browser may have more than one. */
9
+ export const pagesOf = (browser) => browser.contexts().flatMap((c) => c.pages());
10
+ /**
11
+ * Find the one open page a {@link PageMatch} selects, or explain what was open instead.
12
+ *
13
+ * Listing the candidates matters more here than anywhere else in this action: the caller is
14
+ * attaching to a browser they did not launch, so "no match" is nearly always a stale URL or a
15
+ * predicate written against the wrong page — and both are obvious the moment you can see what
16
+ * was actually there.
17
+ *
18
+ * A predicate that throws (a chrome:// page, a document still loading, a cross-origin frame)
19
+ * counts as "did not match" rather than failing the search, but is reported, because a
20
+ * predicate that threw on every page is a very different problem from one that simply found
21
+ * nothing.
22
+ */
23
+ export async function selectPage(pages, match) {
24
+ const describe = () => pages.length ? pages.map((p) => ` ${p.url()}`).join("\n") : " (no pages are open)";
25
+ if (typeof match === "string" || match instanceof RegExp) {
26
+ const hit = pages.find((p) => typeof match === "string" ? p.url().includes(match) : match.test(p.url()));
27
+ if (hit)
28
+ return hit;
29
+ throw new Error(`no open page matched ${String(match)}. Open pages:\n${describe()}`);
30
+ }
31
+ // Sequential, not parallel: stopping at the first hit keeps the predicate off pages it
32
+ // never needed to touch, which matters because it is someone else's live browser.
33
+ const threw = [];
34
+ for (const page of pages) {
35
+ try {
36
+ if (await page.evaluate(match.js))
37
+ return page;
38
+ }
39
+ catch (e) {
40
+ threw.push(` ${page.url()} — ${e instanceof Error ? e.message : String(e)}`);
41
+ }
42
+ }
43
+ const errors = threw.length ? `\nThe predicate threw on:\n${threw.join("\n")}` : "";
44
+ throw new Error(`no open page satisfied \`${match.js}\`. Open pages:\n${describe()}${errors}`);
45
+ }
46
+ /**
47
+ * Resolve a {@link ChromePageSource} down to one live page.
48
+ *
49
+ * With `match` the page must already exist and is searched for across **every** context: a
50
+ * reattached browser belongs to someone else, and nothing says the page you want lives in the
51
+ * context we would otherwise default to. Without `match` the old behaviour stands — take the
52
+ * first page, or open a fresh one when navigating.
53
+ */
54
+ async function acquire(source, opts) {
55
+ const { url, match } = opts;
56
+ const fresh = url !== undefined;
57
+ if (typeof source === "string") {
58
+ const cdpUrl = source.includes("://") ? source : `http://${source}`;
59
+ let owned;
60
+ try {
61
+ owned = await chromium.connectOverCDP(cdpUrl);
62
+ }
63
+ catch (cause) {
64
+ throw new Error(`could not connect to Chrome over CDP at ${cdpUrl}`, { cause });
65
+ }
66
+ const page = match
67
+ ? await selectPage(pagesOf(owned), match)
68
+ : await pageOf(owned.contexts()[0] ?? (await owned.newContext()), fresh);
69
+ return { page, from: cdpUrl, owned };
70
+ }
71
+ if (isPage(source))
72
+ return { page: source, from: "page handle" };
73
+ if (isBrowser(source)) {
74
+ const page = match
75
+ ? await selectPage(pagesOf(source), match)
76
+ : await pageOf(source.contexts()[0] ?? (await source.newContext()), fresh);
77
+ return { page, from: "browser handle" };
78
+ }
79
+ const page = match ? await selectPage(source.pages(), match) : await pageOf(source, fresh);
80
+ return { page, from: "context handle" };
81
+ }
8
82
  /**
9
83
  * Publishes a Chrome/Chromium {@link Page} as this action's handle, so downstream
10
- * actions can await `instance.ref` and drive the same live page. The source may be
11
- * a promise, so it wires straight from another action's `.ref` (e.g.
12
- * `chromePage("Explorer", chromedriver.ref)`). `label` tags this run so several
84
+ * actions can await `instance.ref` and drive the same live page. The source is a
85
+ * resolved handle, not a promise — chain it from another action by awaiting that
86
+ * action's `.ref` at the call site (e.g.
87
+ * `chromePage("Explorer", await chromedriver.ref)`). `label` tags this run so several
13
88
  * pages in one run stay distinguishable. Daemon: acquire + attach, stay alive
14
89
  * until teardown, then close the CDP connection only when it opened one.
15
90
  */
@@ -19,36 +94,9 @@ export const chromePage = action("chromePage")
19
94
  .run(async ({ signal, emit, attach, tag }, label, source, opts = {}) => {
20
95
  tag(label);
21
96
  const { url } = opts;
22
- const resolved = await source;
23
- // Only a CDP connection we open here is ours to close; handles belong to callers.
24
- let owned;
25
- let page;
26
- let from;
27
- if (typeof resolved === "string") {
28
- const cdpUrl = resolved.includes("://") ? resolved : `http://${resolved}`;
29
- from = cdpUrl;
30
- try {
31
- owned = await chromium.connectOverCDP(cdpUrl);
32
- }
33
- catch (cause) {
34
- throw new Error(`could not connect to Chrome over CDP at ${cdpUrl}`, { cause });
35
- }
36
- const context = owned.contexts()[0] ?? (await owned.newContext());
37
- page = await pageOf(context, url !== undefined);
38
- }
39
- else if (isPage(resolved)) {
40
- from = "page handle";
41
- page = resolved;
42
- }
43
- else if (isBrowser(resolved)) {
44
- from = "browser handle";
45
- const context = resolved.contexts()[0] ?? (await resolved.newContext());
46
- page = await pageOf(context, url !== undefined);
47
- }
48
- else {
49
- from = "context handle";
50
- page = await pageOf(resolved, url !== undefined);
51
- }
97
+ // With both `match` and `url`, acquire finds the existing page and this navigates it —
98
+ // "go to that tab, then send it here" rather than opening a second one.
99
+ const { page, from, owned } = await acquire(source, opts);
52
100
  page.on("console", (msg) => {
53
101
  console.log(`console: ${msg.text()}`);
54
102
  emit("console", `from: ${label}: ${msg.text()}`);
@@ -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
+ }
@@ -8,6 +8,8 @@ export { untilLog } from "./until-log.ts";
8
8
  export type { LogMatcher, UntilLogOptions, UntilLogEvents } from "./until-log.ts";
9
9
  export { healthcheck } from "./healthcheck.ts";
10
10
  export type { HealthcheckOptions, HealthcheckResult, HealthcheckEvents, StatusMatcher, BodyMatcher, } from "./healthcheck.ts";
11
+ export { portFree } from "./port-free.ts";
12
+ export type { PortFreeOptions, PortFreeResult, PortFreeEvents } from "./port-free.ts";
11
13
  export { watch } from "./watch.ts";
12
14
  export type { FileEvent, FileListener, WatchOptions } from "./watch.ts";
13
15
  export { watchDir } from "./watch-dir.ts";
@@ -15,8 +17,10 @@ export type { WatchDirOptions, WatchDirEvents } from "./watch-dir.ts";
15
17
  export { tailLog } from "./tail-log.ts";
16
18
  export type { TailLogOptions } from "./tail-log.ts";
17
19
  export { prompt } from "./prompt.ts";
18
- export type { PromptOptions, InputPromptOptions, ChoicePromptOptions, PromptChoice, PromptVia, PromptEvents, } from "./prompt.ts";
19
- export { chromePage } from "./chrome-page.ts";
20
- 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";
21
23
  export { browser } from "./browser.ts";
22
24
  export type { BrowserOptions } from "./browser.ts";
25
+ export { drivePage } from "./drive-page.ts";
26
+ export type { DrivePageOptions, DrivePageResult } from "./drive-page.ts";
@@ -5,9 +5,11 @@
5
5
  export { command } from "./command.js";
6
6
  export { untilLog } from "./until-log.js";
7
7
  export { healthcheck } from "./healthcheck.js";
8
+ export { portFree } from "./port-free.js";
8
9
  export { watch } from "./watch.js";
9
10
  export { watchDir } from "./watch-dir.js";
10
11
  export { tailLog } from "./tail-log.js";
11
12
  export { prompt } from "./prompt.js";
12
- export { chromePage } from "./chrome-page.js";
13
+ export { chromePage, selectPage, pagesOf } from "./chrome-page.js";
13
14
  export { browser } from "./browser.js";
15
+ export { drivePage } from "./drive-page.js";
@@ -0,0 +1,29 @@
1
+ export interface PortFreeOptions {
2
+ /** Host to probe. Default "127.0.0.1". */
3
+ host?: string;
4
+ /** Delay between probes. Default 500ms. */
5
+ intervalMs?: number;
6
+ /** Abort a single connect attempt that hasn't answered within this long. Default 1000ms. */
7
+ connectTimeoutMs?: number;
8
+ /** Reject after this long overall. Default 30000ms; pass `undefined` to wait until teardown. */
9
+ timeoutMs?: number;
10
+ }
11
+ /** The probe that found the port free — this action's result and `free` event payload. */
12
+ export interface PortFreeResult {
13
+ port: number;
14
+ host: string;
15
+ attempts: number;
16
+ }
17
+ /** Events emitted by {@link portFree}. */
18
+ export interface PortFreeEvents {
19
+ /** Nothing is listening any more; payload is the passing probe. */
20
+ free: PortFreeResult;
21
+ }
22
+ /**
23
+ * Gate: polls a TCP port until nothing is listening on it, then resolves with that
24
+ * probe and emits `free`. The mirror of `healthcheck` — where that one waits
25
+ * for a service to come up, this waits for one to let go, so a restart can rebind
26
+ * without racing the previous process's socket. Bounded by the run's abort signal
27
+ * and `timeoutMs`, so teardown unblocks a pending probe instead of hanging.
28
+ */
29
+ export declare const portFree: import("../index.ts").Action<[port: number, opts?: PortFreeOptions | undefined], PortFreeResult, PortFreeEvents, void>;
@@ -0,0 +1,78 @@
1
+ import net from "node:net";
2
+ import { action } from "../core/action.js";
3
+ /**
4
+ * One TCP connect attempt. A refusal is the only answer that proves nobody is
5
+ * listening: a successful connect means the port is taken, and a hang or any other
6
+ * error (filtered, unreachable) is inconclusive, so both count as still busy rather
7
+ * than letting a firewall read as "free".
8
+ */
9
+ const probe = (port, host, connectTimeoutMs, signal) => new Promise((resolve) => {
10
+ const socket = new net.Socket();
11
+ let settled = false;
12
+ const finish = (free) => {
13
+ if (settled)
14
+ return;
15
+ settled = true;
16
+ signal.removeEventListener("abort", onAbort);
17
+ socket.destroy();
18
+ resolve(free);
19
+ };
20
+ // Teardown mid-connect: drop the socket and report "busy" — the loop's own abort
21
+ // check turns that into a clean stop on the next pass.
22
+ function onAbort() {
23
+ finish(false);
24
+ }
25
+ socket.setTimeout(connectTimeoutMs);
26
+ socket.once("connect", () => finish(false));
27
+ socket.once("timeout", () => finish(false));
28
+ socket.once("error", (error) => finish(error.code === "ECONNREFUSED"));
29
+ signal.addEventListener("abort", onAbort, { once: true });
30
+ socket.connect(port, host);
31
+ });
32
+ /**
33
+ * Gate: polls a TCP port until nothing is listening on it, then resolves with that
34
+ * probe and emits `free`. The mirror of `healthcheck` — where that one waits
35
+ * for a service to come up, this waits for one to let go, so a restart can rebind
36
+ * without racing the previous process's socket. Bounded by the run's abort signal
37
+ * and `timeoutMs`, so teardown unblocks a pending probe instead of hanging.
38
+ */
39
+ export const portFree = action("portFree")
40
+ .emits()
41
+ .run(async ({ signal, emit }, port, opts = {}) => {
42
+ const { host = "127.0.0.1", intervalMs = 500, connectTimeoutMs = 1000, timeoutMs = 30_000, } = opts;
43
+ // Abortable inter-probe delay: resolves after `ms`, or rejects on teardown.
44
+ const sleep = (ms) => new Promise((resolve, reject) => {
45
+ const onAbort = () => {
46
+ clearTimeout(timer);
47
+ reject(new Error("portFree: aborted"));
48
+ };
49
+ const timer = setTimeout(() => {
50
+ signal.removeEventListener("abort", onAbort);
51
+ resolve();
52
+ }, ms);
53
+ if (signal.aborted)
54
+ onAbort();
55
+ else
56
+ signal.addEventListener("abort", onAbort, { once: true });
57
+ });
58
+ const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
59
+ const target = `${host}:${port}`;
60
+ console.log(`waiting for ${target} to free up`);
61
+ let attempts = 0;
62
+ for (;;) {
63
+ if (signal.aborted)
64
+ throw new Error("portFree: aborted");
65
+ attempts++;
66
+ if (await probe(port, host, connectTimeoutMs, signal)) {
67
+ const result = { port, host, attempts };
68
+ const probes = `${attempts} ${attempts === 1 ? "probe" : "probes"}`;
69
+ console.log(`free ${target} (${probes})`);
70
+ emit("free", result);
71
+ return result;
72
+ }
73
+ if (deadline !== undefined && Date.now() >= deadline) {
74
+ throw new Error(`portFree: ${target} still in use after ${timeoutMs}ms (${attempts} probes)`);
75
+ }
76
+ await sleep(intervalMs);
77
+ }
78
+ });
@@ -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,18 @@ 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> the project the skill describes, if the tsconfig sits below it (skill)",
22
28
  " --inspect[=port] run the om in a forked child with the Node inspector open (default port 9229)",
23
29
  " -h, --help show this help",
24
30
  "",
@@ -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;