omkit 0.4.8 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +153 -0
- package/README.md +168 -12
- package/dist/actions/chrome-page.d.ts +41 -0
- package/dist/actions/chrome-page.js +77 -29
- package/dist/actions/drive-page.d.ts +50 -0
- package/dist/actions/drive-page.js +68 -0
- package/dist/actions/index.d.ts +5 -3
- package/dist/actions/index.js +2 -1
- 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
|
@@ -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
|
@@ -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";
|
package/dist/actions/index.js
CHANGED
|
@@ -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";
|
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;
|
package/dist/cli/commands/ls.js
CHANGED
|
@@ -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
|
|
6
|
-
const
|
|
7
|
-
lines
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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,17 @@
|
|
|
1
|
+
import type { OmkitClient } from "../../client/index.ts";
|
|
2
|
+
export interface SkillOptions {
|
|
3
|
+
/** Project root the skill is written relative to — the directory owning the tsconfig. */
|
|
4
|
+
readonly root: string;
|
|
5
|
+
/** Override the output path (still resolved against `root` when relative). */
|
|
6
|
+
readonly out?: string;
|
|
7
|
+
/** Compare hashes and exit non-zero on drift instead of writing. */
|
|
8
|
+
readonly check?: boolean;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The `skill` command: generate `.claude/skills/omkit-runs/SKILL.md` from what this project's
|
|
12
|
+
* oms and actions declare, so an assistant knows what is runnable before its first tool call.
|
|
13
|
+
*
|
|
14
|
+
* Generation runs no workflow. It uses the same `OMKIT_DISCOVER=1` fork the MCP server's
|
|
15
|
+
* `list_oms` uses, which returns from `launch()` before an execution tree exists.
|
|
16
|
+
*/
|
|
17
|
+
export declare function skillCommand(client: OmkitClient, opts: SkillOptions): Promise<void>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { buildSkillModel, readExisting, renderSkill, writeSkill, DEFAULT_DESCRIPTION, SKILL_RELATIVE_PATH, } from "../../skill/index.js";
|
|
3
|
+
const EMPTY_REGISTRY = { oms: [], actions: [], warnings: [] };
|
|
4
|
+
/**
|
|
5
|
+
* The `skill` command: generate `.claude/skills/omkit-runs/SKILL.md` from what this project's
|
|
6
|
+
* oms and actions declare, so an assistant knows what is runnable before its first tool call.
|
|
7
|
+
*
|
|
8
|
+
* Generation runs no workflow. It uses the same `OMKIT_DISCOVER=1` fork the MCP server's
|
|
9
|
+
* `list_oms` uses, which returns from `launch()` before an execution tree exists.
|
|
10
|
+
*/
|
|
11
|
+
export async function skillCommand(client, opts) {
|
|
12
|
+
const file = path.resolve(opts.root, opts.out ?? SKILL_RELATIVE_PATH);
|
|
13
|
+
const registrations = await client.discoverRegistrations();
|
|
14
|
+
const model = buildSkillModel(registrations, registrations.registry ?? EMPTY_REGISTRY, opts.root);
|
|
15
|
+
const existing = readExisting(file);
|
|
16
|
+
if (opts.check) {
|
|
17
|
+
reportCheck(file, existing.hash, model.hash);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
// The description is the skill's loading trigger and is authored, so an existing one always
|
|
21
|
+
// wins; the fallback is only ever written into a file that does not exist yet.
|
|
22
|
+
const content = renderSkill(model, { description: existing.description ?? DEFAULT_DESCRIPTION });
|
|
23
|
+
writeSkill(file, content);
|
|
24
|
+
const total = model.oms.length + model.actions.length;
|
|
25
|
+
console.log(`wrote ${path.relative(opts.root, file) || file}`);
|
|
26
|
+
console.log(` ${model.oms.length} om${model.oms.length === 1 ? "" : "s"}, ` +
|
|
27
|
+
`${model.actions.length} action${model.actions.length === 1 ? "" : "s"} ` +
|
|
28
|
+
`[registry-hash: ${model.hash}]`);
|
|
29
|
+
if (total === 0) {
|
|
30
|
+
console.log(" nothing is exposed yet — mark an om or action with .mcp() to list it");
|
|
31
|
+
}
|
|
32
|
+
reportWarnings(registrations.warnings);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* `--check` is for a hook or a CI step, so it says what changed and sets the exit code rather
|
|
36
|
+
* than writing. A file with no marker at all counts as drift: it was hand-written or predates
|
|
37
|
+
* the generator, and either way regenerating is the fix.
|
|
38
|
+
*/
|
|
39
|
+
function reportCheck(file, existing, fresh) {
|
|
40
|
+
if (existing === fresh) {
|
|
41
|
+
console.log(`up to date [registry-hash: ${fresh}]`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const detail = existing === undefined ? "no registry-hash found" : `has ${existing}`;
|
|
45
|
+
console.error(`${file} is stale — ${detail}, project is ${fresh}`);
|
|
46
|
+
console.error("run `omkit skill` to regenerate");
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
}
|
|
49
|
+
/** Discovery degrades to warnings rather than failing, so they have to reach the terminal. */
|
|
50
|
+
function reportWarnings(warnings) {
|
|
51
|
+
if (warnings.length === 0)
|
|
52
|
+
return;
|
|
53
|
+
console.error(`\nwarnings (${warnings.length})`);
|
|
54
|
+
for (const w of warnings)
|
|
55
|
+
console.error(` ${w}`);
|
|
56
|
+
}
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -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>`: the project the skill describes, when the tsconfig sits below it. */
|
|
17
|
+
root?: string;
|
|
10
18
|
/** The Node inspector, if requested via `--inspect[=port]`. */
|
|
11
19
|
inspect?: InspectOptions;
|
|
12
20
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -33,6 +33,10 @@ export function parseCli(argv) {
|
|
|
33
33
|
json: { type: "boolean", default: false },
|
|
34
34
|
tsconfig: { type: "string", default: "tsconfig.omkit.json" },
|
|
35
35
|
help: { type: "boolean", short: "h", default: false },
|
|
36
|
+
describe: { type: "boolean", default: false },
|
|
37
|
+
check: { type: "boolean", default: false },
|
|
38
|
+
out: { type: "string" },
|
|
39
|
+
root: { type: "string" },
|
|
36
40
|
},
|
|
37
41
|
allowPositionals: true,
|
|
38
42
|
});
|
|
@@ -42,6 +46,10 @@ export function parseCli(argv) {
|
|
|
42
46
|
json: Boolean(values.json),
|
|
43
47
|
tsconfig: values.tsconfig,
|
|
44
48
|
help: Boolean(values.help),
|
|
49
|
+
describe: Boolean(values.describe),
|
|
50
|
+
check: Boolean(values.check),
|
|
51
|
+
...(values.out === undefined ? {} : { out: values.out }),
|
|
52
|
+
...(values.root === undefined ? {} : { root: values.root }),
|
|
45
53
|
inspect,
|
|
46
54
|
};
|
|
47
55
|
}
|
|
@@ -62,6 +70,62 @@ async function checkCommand(client) {
|
|
|
62
70
|
await renderDiagnostics({ kind: n === 0 ? "ok" : "error", title, items }, n === 0 ? process.stdout : process.stderr);
|
|
63
71
|
process.exitCode = n === 0 ? 0 : 1;
|
|
64
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* The `ls` command: print the discovered oms and actions.
|
|
75
|
+
*
|
|
76
|
+
* `--describe` is the only path here that imports user code. Without it `ls` is the AST scan
|
|
77
|
+
* and nothing else — the property that keeps it instant and safe to run in any repo, including
|
|
78
|
+
* one whose oms would start servers if they were evaluated.
|
|
79
|
+
*/
|
|
80
|
+
async function lsCommand(client, cli) {
|
|
81
|
+
const { formatRegistry } = await import("./commands/ls.js");
|
|
82
|
+
const { withSpinner } = await import("./ui/Report.js");
|
|
83
|
+
if (!cli.describe) {
|
|
84
|
+
const registry = await withSpinner("discovering…", () => client.discover());
|
|
85
|
+
console.log(formatRegistry(registry, { json: cli.json }));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const registrations = await withSpinner("reading declarations…", () => client.discoverRegistrations());
|
|
89
|
+
const registry = registrations.registry ?? (await client.discover());
|
|
90
|
+
console.log(formatRegistry(registry, { json: cli.json, registrations }));
|
|
91
|
+
}
|
|
92
|
+
/** The `init` command: scaffold a starter project in the current directory. */
|
|
93
|
+
async function initCommand() {
|
|
94
|
+
const { scaffold } = await import("./commands/init.js");
|
|
95
|
+
const { created, skipped } = scaffold(process.cwd());
|
|
96
|
+
for (const f of created)
|
|
97
|
+
console.log(`created ${f}`);
|
|
98
|
+
for (const f of skipped)
|
|
99
|
+
console.log(`exists, skipped ${f}`);
|
|
100
|
+
}
|
|
101
|
+
/** The `skill` command: generate the runnable-workflow map for an assistant to read. */
|
|
102
|
+
async function generateSkill(client, cli) {
|
|
103
|
+
const { skillCommand } = await import("./commands/skill.js");
|
|
104
|
+
// The root decides two things at once — where `.claude/` goes, and what the recorded paths are
|
|
105
|
+
// relative to — so it is one setting, not two. It defaults to the tsconfig's directory (the
|
|
106
|
+
// common layout, where they are the same place); `--root` covers the repo that keeps its om
|
|
107
|
+
// project in a subfolder, where the reader's cwd is the repo and so the paths must be too.
|
|
108
|
+
const root = cli.root ? path.resolve(cli.root) : path.dirname(path.resolve(cli.tsconfig));
|
|
109
|
+
await skillCommand(client, { root, check: cli.check, ...(cli.out ? { out: cli.out } : {}) });
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Every command, by name. A table rather than a chain of `if`s: each entry is one lazy import
|
|
113
|
+
* and one call, so adding a command cannot quietly grow the entry point's branching.
|
|
114
|
+
*/
|
|
115
|
+
const COMMANDS = {
|
|
116
|
+
init: () => initCommand(),
|
|
117
|
+
ls: (client, cli) => lsCommand(client, cli),
|
|
118
|
+
check: (client) => checkCommand(client),
|
|
119
|
+
mcp: async (client) => {
|
|
120
|
+
const { mcpCommand } = await import("./commands/mcp.js");
|
|
121
|
+
await mcpCommand(client, { root: process.cwd() });
|
|
122
|
+
},
|
|
123
|
+
skill: (client, cli) => generateSkill(client, cli),
|
|
124
|
+
run: async (client, cli) => {
|
|
125
|
+
const { runCommand } = await import("./commands/run.js");
|
|
126
|
+
await runCommand(client, { target: cli.target, inspect: cli.inspect });
|
|
127
|
+
},
|
|
128
|
+
};
|
|
65
129
|
/** The bin entry: route to a lazily-imported command; owns stdout and the exit code. */
|
|
66
130
|
async function main() {
|
|
67
131
|
const cli = parseCli(process.argv.slice(2));
|
|
@@ -71,37 +135,15 @@ async function main() {
|
|
|
71
135
|
console.log(helpText());
|
|
72
136
|
return;
|
|
73
137
|
}
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
console.log(`created ${f}`);
|
|
81
|
-
for (const f of skipped)
|
|
82
|
-
console.log(`exists, skipped ${f}`);
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
if (cli.command === "ls") {
|
|
86
|
-
const { formatRegistry } = await import("./commands/ls.js");
|
|
87
|
-
const { withSpinner } = await import("./ui/Report.js");
|
|
88
|
-
const registry = await withSpinner("discovering…", () => client.discover());
|
|
89
|
-
console.log(formatRegistry(registry, { json: cli.json }));
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
if (cli.command === "check") {
|
|
93
|
-
await checkCommand(client);
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
if (cli.command === "run") {
|
|
97
|
-
const { runCommand } = await import("./commands/run.js");
|
|
98
|
-
await runCommand(client, { target: cli.target, inspect: cli.inspect });
|
|
138
|
+
const handler = COMMANDS[cli.command];
|
|
139
|
+
if (!handler) {
|
|
140
|
+
const { helpText } = await import("./commands/help.js");
|
|
141
|
+
console.error(`unknown command "${cli.command}"\n`);
|
|
142
|
+
console.error(helpText());
|
|
143
|
+
process.exitCode = 1;
|
|
99
144
|
return;
|
|
100
145
|
}
|
|
101
|
-
|
|
102
|
-
console.error(`unknown command "${cli.command}"\n`);
|
|
103
|
-
console.error(helpText());
|
|
104
|
-
process.exitCode = 1;
|
|
146
|
+
await handler(createOmkitClient({ tsconfig: cli.tsconfig, inspect: cli.inspect }), cli);
|
|
105
147
|
}
|
|
106
148
|
/**
|
|
107
149
|
* True when this module is the process entry point — not when a test imports `parseCli`.
|