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.
- package/CHANGELOG.md +153 -0
- package/README.md +170 -13
- package/dist/actions/browser.d.ts +1 -1
- package/dist/actions/browser.js +1 -1
- package/dist/actions/chrome-page.d.ts +46 -4
- package/dist/actions/chrome-page.js +81 -33
- package/dist/actions/drive-page.d.ts +50 -0
- package/dist/actions/drive-page.js +68 -0
- package/dist/actions/index.d.ts +7 -3
- package/dist/actions/index.js +3 -1
- package/dist/actions/port-free.d.ts +29 -0
- package/dist/actions/port-free.js +78 -0
- package/dist/actions/prompt.d.ts +32 -5
- package/dist/actions/prompt.js +92 -8
- package/dist/cli/commands/help.js +6 -0
- package/dist/cli/commands/init.js +1 -1
- package/dist/cli/commands/ls.d.ts +11 -4
- package/dist/cli/commands/ls.js +35 -15
- package/dist/cli/commands/mcp.d.ts +10 -0
- package/dist/cli/commands/mcp.js +13 -0
- package/dist/cli/commands/skill.d.ts +17 -0
- package/dist/cli/commands/skill.js +56 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.js +71 -29
- package/dist/client/discover-child.d.ts +12 -0
- package/dist/client/discover-child.js +71 -0
- package/dist/client/discovery.d.ts +21 -1
- package/dist/client/discovery.js +139 -2
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.js +3 -1
- package/dist/client/outline.d.ts +35 -0
- package/dist/client/outline.js +100 -0
- package/dist/client/registry.d.ts +59 -0
- package/dist/client/runner.d.ts +12 -0
- package/dist/client/runner.js +16 -7
- package/dist/client/types.d.ts +9 -1
- package/dist/core/ActionRun.d.ts +5 -0
- package/dist/core/ActionRun.js +9 -0
- package/dist/core/ExecutionTree.d.ts +40 -2
- package/dist/core/ExecutionTree.js +66 -2
- package/dist/core/action.d.ts +2 -1
- package/dist/core/action.js +93 -9
- package/dist/core/args.d.ts +50 -0
- package/dist/core/args.js +166 -0
- package/dist/core/discovery-mode.d.ts +37 -0
- package/dist/core/discovery-mode.js +54 -0
- package/dist/core/interaction.d.ts +3 -1
- package/dist/core/om.d.ts +11 -9
- package/dist/core/om.js +182 -13
- package/dist/core/schema-hint.d.ts +20 -0
- package/dist/core/schema-hint.js +73 -0
- package/dist/core/schema-json.d.ts +12 -0
- package/dist/core/schema-json.js +12 -0
- package/dist/core/types.d.ts +113 -1
- package/dist/foundation/LogEntry.d.ts +1 -1
- package/dist/foundation/canonicalPath.d.ts +26 -0
- package/dist/foundation/canonicalPath.js +34 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mcp/action-host.d.ts +1 -0
- package/dist/mcp/action-host.js +65 -0
- package/dist/mcp/action-identity.d.ts +23 -0
- package/dist/mcp/action-identity.js +35 -0
- package/dist/mcp/progress.d.ts +61 -0
- package/dist/mcp/progress.js +92 -0
- package/dist/mcp/resources.d.ts +55 -0
- package/dist/mcp/resources.js +208 -0
- package/dist/mcp/runs.d.ts +37 -0
- package/dist/mcp/runs.js +60 -0
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +38 -0
- package/dist/mcp/tail.d.ts +37 -0
- package/dist/mcp/tail.js +64 -0
- package/dist/mcp/tools.d.ts +54 -0
- package/dist/mcp/tools.js +374 -0
- package/dist/mcp/validate.d.ts +14 -0
- package/dist/mcp/validate.js +54 -0
- package/dist/mcp/verdict.d.ts +31 -0
- package/dist/mcp/verdict.js +47 -0
- package/dist/output/artifact/ArtifactStore.d.ts +24 -0
- package/dist/output/artifact/ArtifactStore.js +49 -0
- package/dist/output/folder/RunFolder.d.ts +23 -1
- package/dist/output/folder/RunFolder.js +55 -11
- package/dist/output/writers/views.d.ts +10 -0
- package/dist/skill/file.d.ts +26 -0
- package/dist/skill/file.js +58 -0
- package/dist/skill/index.d.ts +11 -0
- package/dist/skill/index.js +9 -0
- package/dist/skill/model.d.ts +55 -0
- package/dist/skill/model.js +116 -0
- package/dist/skill/render.d.ts +18 -0
- package/dist/skill/render.js +133 -0
- 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
|
|
11
|
-
* a promise
|
|
12
|
-
*
|
|
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
|
-
|
|
23
|
-
//
|
|
24
|
-
|
|
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
|
+
}
|
package/dist/actions/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/actions/index.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/actions/prompt.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 **
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* unattended run never blocks; teardown
|
|
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 {};
|
package/dist/actions/prompt.js
CHANGED
|
@@ -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 **
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* unattended run never blocks; teardown
|
|
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 === "
|
|
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"
|
|
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;
|