omkit 0.4.8 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/README.md +169 -13
  3. package/dist/actions/chrome-page.d.ts +41 -0
  4. package/dist/actions/chrome-page.js +77 -29
  5. package/dist/actions/drive-page.d.ts +50 -0
  6. package/dist/actions/drive-page.js +68 -0
  7. package/dist/actions/index.d.ts +5 -3
  8. package/dist/actions/index.js +2 -1
  9. package/dist/actions/prompt.d.ts +32 -5
  10. package/dist/actions/prompt.js +92 -8
  11. package/dist/cli/commands/help.js +7 -0
  12. package/dist/cli/commands/init.js +1 -1
  13. package/dist/cli/commands/ls.d.ts +11 -4
  14. package/dist/cli/commands/ls.js +35 -15
  15. package/dist/cli/commands/mcp.d.ts +10 -0
  16. package/dist/cli/commands/mcp.js +13 -0
  17. package/dist/cli/commands/skill.d.ts +18 -0
  18. package/dist/cli/commands/skill.js +62 -0
  19. package/dist/cli/index.d.ts +8 -0
  20. package/dist/cli/index.js +78 -30
  21. package/dist/client/discover-child.d.ts +12 -0
  22. package/dist/client/discover-child.js +71 -0
  23. package/dist/client/discovery.d.ts +21 -1
  24. package/dist/client/discovery.js +139 -2
  25. package/dist/client/index.d.ts +2 -1
  26. package/dist/client/index.js +15 -5
  27. package/dist/client/outline.d.ts +35 -0
  28. package/dist/client/outline.js +100 -0
  29. package/dist/client/registry.d.ts +59 -0
  30. package/dist/client/runner.d.ts +12 -0
  31. package/dist/client/runner.js +16 -7
  32. package/dist/client/types.d.ts +14 -1
  33. package/dist/client/types.js +5 -1
  34. package/dist/core/ActionRun.d.ts +5 -0
  35. package/dist/core/ActionRun.js +9 -0
  36. package/dist/core/ExecutionTree.d.ts +40 -2
  37. package/dist/core/ExecutionTree.js +66 -2
  38. package/dist/core/action.d.ts +2 -1
  39. package/dist/core/action.js +93 -9
  40. package/dist/core/args.d.ts +50 -0
  41. package/dist/core/args.js +166 -0
  42. package/dist/core/discovery-mode.d.ts +37 -0
  43. package/dist/core/discovery-mode.js +54 -0
  44. package/dist/core/interaction.d.ts +3 -1
  45. package/dist/core/om.d.ts +11 -9
  46. package/dist/core/om.js +182 -13
  47. package/dist/core/schema-hint.d.ts +20 -0
  48. package/dist/core/schema-hint.js +73 -0
  49. package/dist/core/schema-json.d.ts +12 -0
  50. package/dist/core/schema-json.js +12 -0
  51. package/dist/core/types.d.ts +113 -1
  52. package/dist/foundation/LogEntry.d.ts +1 -1
  53. package/dist/foundation/canonicalPath.d.ts +26 -0
  54. package/dist/foundation/canonicalPath.js +34 -0
  55. package/dist/index.d.ts +2 -2
  56. package/dist/index.js +1 -1
  57. package/dist/mcp/action-host.d.ts +1 -0
  58. package/dist/mcp/action-host.js +65 -0
  59. package/dist/mcp/action-identity.d.ts +23 -0
  60. package/dist/mcp/action-identity.js +35 -0
  61. package/dist/mcp/progress.d.ts +61 -0
  62. package/dist/mcp/progress.js +92 -0
  63. package/dist/mcp/resources.d.ts +55 -0
  64. package/dist/mcp/resources.js +208 -0
  65. package/dist/mcp/runs.d.ts +37 -0
  66. package/dist/mcp/runs.js +60 -0
  67. package/dist/mcp/server.d.ts +19 -0
  68. package/dist/mcp/server.js +38 -0
  69. package/dist/mcp/tail.d.ts +37 -0
  70. package/dist/mcp/tail.js +64 -0
  71. package/dist/mcp/tools.d.ts +54 -0
  72. package/dist/mcp/tools.js +378 -0
  73. package/dist/mcp/validate.d.ts +14 -0
  74. package/dist/mcp/validate.js +54 -0
  75. package/dist/mcp/verdict.d.ts +31 -0
  76. package/dist/mcp/verdict.js +47 -0
  77. package/dist/output/artifact/ArtifactStore.d.ts +24 -0
  78. package/dist/output/artifact/ArtifactStore.js +49 -0
  79. package/dist/output/folder/RunFolder.d.ts +23 -1
  80. package/dist/output/folder/RunFolder.js +70 -11
  81. package/dist/output/writers/views.d.ts +10 -0
  82. package/dist/skill/file.d.ts +26 -0
  83. package/dist/skill/file.js +58 -0
  84. package/dist/skill/index.d.ts +12 -0
  85. package/dist/skill/index.js +10 -0
  86. package/dist/skill/model.d.ts +69 -0
  87. package/dist/skill/model.js +200 -0
  88. package/dist/skill/render.d.ts +18 -0
  89. package/dist/skill/render.js +159 -0
  90. package/dist/skill/root.d.ts +15 -0
  91. package/dist/skill/root.js +27 -0
  92. package/package.json +5 -2
package/dist/cli/index.js CHANGED
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { realpathSync } from "node:fs";
6
6
  import { createOmkitClient } from "../client/index.js";
7
+ import { DEFAULT_TSCONFIG } from "../client/types.js";
7
8
  /**
8
9
  * Pull the Node-style inspector flag out of argv, returning the options plus the remaining args.
9
10
  * Matches Node's own syntax: `--inspect`, `--inspect=PORT` (default port 9229); an explicit port
@@ -31,8 +32,12 @@ export function parseCli(argv) {
31
32
  args: rest,
32
33
  options: {
33
34
  json: { type: "boolean", default: false },
34
- tsconfig: { type: "string", default: "tsconfig.omkit.json" },
35
+ tsconfig: { type: "string", default: DEFAULT_TSCONFIG },
35
36
  help: { type: "boolean", short: "h", default: false },
37
+ describe: { type: "boolean", default: false },
38
+ check: { type: "boolean", default: false },
39
+ out: { type: "string" },
40
+ root: { type: "string" },
36
41
  },
37
42
  allowPositionals: true,
38
43
  });
@@ -42,6 +47,10 @@ export function parseCli(argv) {
42
47
  json: Boolean(values.json),
43
48
  tsconfig: values.tsconfig,
44
49
  help: Boolean(values.help),
50
+ describe: Boolean(values.describe),
51
+ check: Boolean(values.check),
52
+ ...(values.out === undefined ? {} : { out: values.out }),
53
+ ...(values.root === undefined ? {} : { root: values.root }),
45
54
  inspect,
46
55
  };
47
56
  }
@@ -62,6 +71,67 @@ async function checkCommand(client) {
62
71
  await renderDiagnostics({ kind: n === 0 ? "ok" : "error", title, items }, n === 0 ? process.stdout : process.stderr);
63
72
  process.exitCode = n === 0 ? 0 : 1;
64
73
  }
74
+ /**
75
+ * The `ls` command: print the discovered oms and actions.
76
+ *
77
+ * `--describe` is the only path here that imports user code. Without it `ls` is the AST scan
78
+ * and nothing else — the property that keeps it instant and safe to run in any repo, including
79
+ * one whose oms would start servers if they were evaluated.
80
+ */
81
+ async function lsCommand(client, cli) {
82
+ const { formatRegistry } = await import("./commands/ls.js");
83
+ const { withSpinner } = await import("./ui/Report.js");
84
+ if (!cli.describe) {
85
+ const registry = await withSpinner("discovering…", () => client.discover());
86
+ console.log(formatRegistry(registry, { json: cli.json }));
87
+ return;
88
+ }
89
+ const registrations = await withSpinner("reading declarations…", () => client.discoverRegistrations());
90
+ const registry = registrations.registry ?? (await client.discover());
91
+ console.log(formatRegistry(registry, { json: cli.json, registrations }));
92
+ }
93
+ /** The `init` command: scaffold a starter project in the current directory. */
94
+ async function initCommand() {
95
+ const { scaffold } = await import("./commands/init.js");
96
+ const { created, skipped } = scaffold(process.cwd());
97
+ for (const f of created)
98
+ console.log(`created ${f}`);
99
+ for (const f of skipped)
100
+ console.log(`exists, skipped ${f}`);
101
+ }
102
+ /** The `skill` command: generate the runnable-workflow map for an assistant to read. */
103
+ async function generateSkill(client, cli) {
104
+ const { skillCommand, skillRoot } = await import("./commands/skill.js");
105
+ // The root decides two things at once — where `.claude/` goes, and what the recorded paths are
106
+ // relative to — so it is one setting, not two. It defaults to the enclosing repository, which
107
+ // is both where `.claude/` belongs and where the file's reader will be standing; `--root`
108
+ // remains for the project that is deliberately not at the top of its checkout.
109
+ const root = cli.root
110
+ ? path.resolve(cli.root)
111
+ : skillRoot(path.dirname(path.resolve(cli.tsconfig)));
112
+ await skillCommand(client, { root, check: cli.check, ...(cli.out ? { out: cli.out } : {}) });
113
+ }
114
+ /**
115
+ * Every command, by name. A table rather than a chain of `if`s: each entry is one lazy import
116
+ * and one call, so adding a command cannot quietly grow the entry point's branching.
117
+ */
118
+ const COMMANDS = {
119
+ init: () => initCommand(),
120
+ ls: (client, cli) => lsCommand(client, cli),
121
+ check: (client) => checkCommand(client),
122
+ mcp: async (client, cli) => {
123
+ const { mcpCommand } = await import("./commands/mcp.js");
124
+ // The project owning the config, not the directory the server was launched from: run
125
+ // folders are written under it, so reading them back has to use the same answer. An
126
+ // assistant that starts the server from anywhere still sees the project's own runs.
127
+ await mcpCommand(client, { root: path.dirname(path.resolve(cli.tsconfig)) });
128
+ },
129
+ skill: (client, cli) => generateSkill(client, cli),
130
+ run: async (client, cli) => {
131
+ const { runCommand } = await import("./commands/run.js");
132
+ await runCommand(client, { target: cli.target, inspect: cli.inspect });
133
+ },
134
+ };
65
135
  /** The bin entry: route to a lazily-imported command; owns stdout and the exit code. */
66
136
  async function main() {
67
137
  const cli = parseCli(process.argv.slice(2));
@@ -71,37 +141,15 @@ async function main() {
71
141
  console.log(helpText());
72
142
  return;
73
143
  }
74
- const inspect = cli.inspect;
75
- const client = createOmkitClient({ tsconfig: cli.tsconfig, inspect });
76
- if (cli.command === "init") {
77
- const { scaffold } = await import("./commands/init.js");
78
- const { created, skipped } = scaffold(process.cwd());
79
- for (const f of created)
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 });
144
+ const handler = COMMANDS[cli.command];
145
+ if (!handler) {
146
+ const { helpText } = await import("./commands/help.js");
147
+ console.error(`unknown command "${cli.command}"\n`);
148
+ console.error(helpText());
149
+ process.exitCode = 1;
99
150
  return;
100
151
  }
101
- const { helpText } = await import("./commands/help.js");
102
- console.error(`unknown command "${cli.command}"\n`);
103
- console.error(helpText());
104
- process.exitCode = 1;
152
+ await handler(createOmkitClient({ tsconfig: cli.tsconfig, inspect: cli.inspect }), cli);
105
153
  }
106
154
  /**
107
155
  * True when this module is the process entry point — not when a test imports `parseCli`.
@@ -0,0 +1,12 @@
1
+ import type { ActionRegistration } from "./registry.ts";
2
+ /** The parent's opening message: sent over IPC, so no argv length limit applies. */
3
+ export interface ScanMessage {
4
+ readonly kind: "scan";
5
+ readonly files: string[];
6
+ }
7
+ /** The child's closing message. Om registrations arrive separately, as each om is reached. */
8
+ export interface ActionsMessage {
9
+ readonly kind: "actions";
10
+ readonly actions: ActionRegistration[];
11
+ readonly warnings: string[];
12
+ }
@@ -0,0 +1,71 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { pathToFileURL } from "node:url";
10
+ function asAction(value) {
11
+ if (typeof value !== "function")
12
+ return undefined;
13
+ const candidate = value;
14
+ if (typeof candidate.actionName !== "string")
15
+ return undefined;
16
+ if (typeof candidate.describeArgs !== "function")
17
+ return undefined;
18
+ return candidate;
19
+ }
20
+ /**
21
+ * The discovery child. Forked with `OMKIT_DISCOVER=1`, so importing a file makes every
22
+ * `om(...).run(...)` in it post a registration up this same channel instead of launching
23
+ * (see core/discovery-mode.ts). Actions post nothing — they are plain values — so their
24
+ * metadata is read off each module's exports here, afterwards.
25
+ */
26
+ async function scan(files) {
27
+ const actions = [];
28
+ const warnings = [];
29
+ for (const file of files) {
30
+ let mod;
31
+ try {
32
+ mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href)));
33
+ }
34
+ catch (e) {
35
+ warnings.push(`${file}: ${e instanceof Error ? e.message : String(e)}`);
36
+ continue;
37
+ }
38
+ for (const [exportName, value] of Object.entries(mod)) {
39
+ const found = asAction(value);
40
+ if (!found)
41
+ continue;
42
+ const { inputSchema, schemaError } = found.describeArgs();
43
+ actions.push({
44
+ name: found.actionName,
45
+ exportName,
46
+ file,
47
+ ...(found.description ? { summary: found.description.summary } : {}),
48
+ ...(found.mcp ? { mcp: found.mcp } : {}),
49
+ ...(inputSchema ? { inputSchema } : {}),
50
+ ...(schemaError ? { unavailable: schemaError } : {}),
51
+ });
52
+ }
53
+ }
54
+ // Exit only once the message has actually flushed. `process.send` is asynchronous —
55
+ // exiting on the next line can drop the payload before it reaches the parent, and the
56
+ // parent would see a `close` with no `actions` message and report an empty project.
57
+ // A user module may also have started a server or a timer at import; nothing here is
58
+ // supervised and nothing will tear it down, so leave deliberately rather than linger.
59
+ const done = () => process.exit(0);
60
+ if (process.send) {
61
+ process.send({ kind: "actions", actions, warnings }, done);
62
+ }
63
+ else {
64
+ done();
65
+ }
66
+ }
67
+ process.on("message", (message) => {
68
+ if (message?.kind === "scan") {
69
+ void scan(message.files);
70
+ }
71
+ });
@@ -1,4 +1,4 @@
1
- import type { Registry } from "./registry.ts";
1
+ import type { Registry, RegistrationSet } from "./registry.ts";
2
2
  /**
3
3
  * Statically scan the project described by `tsconfigPath` for runnable oms and inspectable
4
4
  * actions. Type errors in the user's oms/actions degrade to `warnings` (an editor-in-progress
@@ -6,3 +6,23 @@ import type { Registry } from "./registry.ts";
6
6
  * tsconfig — throws, so the CLI crashes visibly instead of silently returning an empty project.
7
7
  */
8
8
  export declare function discover(tsconfigPath: string): Registry;
9
+ /**
10
+ * Import `files` in a throwaway child with `OMKIT_DISCOVER=1` and collect what they
11
+ * declare. Exposed separately from {@link discoverRegistrations} so the fork can be driven
12
+ * with an explicit file list — by tests, and by anything that already knows its candidates.
13
+ *
14
+ * Never rejects for user-code problems: an import failure, a crashed child, or a child
15
+ * that outstays its timeout all come back as warnings on an otherwise usable set.
16
+ */
17
+ export declare function readRegistrations(files: string[], opts?: {
18
+ cwd?: string;
19
+ timeoutMs?: number;
20
+ }): Promise<RegistrationSet>;
21
+ /**
22
+ * The full pass: the AST scan picks the candidate files — it stays the only cheap,
23
+ * never-executing phase — then {@link readRegistrations} imports just those. AST warnings
24
+ * are carried through so nothing is lost between the two phases.
25
+ */
26
+ export declare function discoverRegistrations(tsconfigPath: string, opts?: {
27
+ timeoutMs?: number;
28
+ }): Promise<RegistrationSet>;
@@ -1,5 +1,12 @@
1
1
  import ts from "typescript";
2
2
  import path from "node:path";
3
+ import { fork } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { omHash } from "../foundation/ids.js";
6
+ import { fsSafe } from "../foundation/fsSafe.js";
7
+ import { canonicalPath } from "../foundation/canonicalPath.js";
8
+ import { tsxLoader } from "./runner.js";
9
+ import { chainRoot, importedNames, outlineBody } from "./outline.js";
3
10
  /**
4
11
  * Statically scan the project described by `tsconfigPath` for runnable oms and inspectable
5
12
  * actions. Type errors in the user's oms/actions degrade to `warnings` (an editor-in-progress
@@ -20,16 +27,28 @@ export function discover(tsconfigPath) {
20
27
  for (const sf of program.getSourceFiles()) {
21
28
  if (!fileSet.has(canonical(sf.fileName)))
22
29
  continue;
30
+ // TypeScript hands back its own spelling of the path — on Windows it lowercases the drive
31
+ // letter — and every consumer downstream keys on this string: the discovery fork imports
32
+ // it, `omHash` hashes it, and Node caches modules by it. Canonicalise once here so the
33
+ // whole chain agrees with the filesystem, and with the paths a real run reports.
34
+ const file = canonicalPath(sf.fileName);
23
35
  for (const d of program.getSemanticDiagnostics(sf))
24
36
  warnings.push(formatDiagnostic(d));
25
37
  const names = omkitImports(sf);
38
+ const outlines = outlinesIn(sf, names.om, importedNames(sf));
26
39
  const visit = (node) => {
27
40
  if (ts.isCallExpression(node) &&
28
41
  ts.isIdentifier(node.expression) &&
29
42
  (node.expression.text === names.om || node.expression.text === names.step)) {
30
43
  const arg = node.arguments[0];
31
44
  if (arg && ts.isStringLiteralLike(arg)) {
32
- oms.push({ name: arg.text, file: sf.fileName, line: lineOf(sf, node) });
45
+ const calls = outlines.get(arg.text);
46
+ oms.push({
47
+ name: arg.text,
48
+ file,
49
+ line: lineOf(sf, node),
50
+ ...(calls?.length ? { calls } : {}),
51
+ });
33
52
  }
34
53
  }
35
54
  ts.forEachChild(node, visit);
@@ -45,7 +64,7 @@ export function discover(tsconfigPath) {
45
64
  if (info) {
46
65
  actions.push({
47
66
  name: info.name,
48
- file: sf.fileName,
67
+ file,
49
68
  exportName: decl.name.text,
50
69
  publishesCapability: info.ref,
51
70
  events: info.emits,
@@ -56,6 +75,124 @@ export function discover(tsconfigPath) {
56
75
  }
57
76
  return { oms, actions, warnings };
58
77
  }
78
+ /**
79
+ * Outline every `om("name")…​.run(body)` chain in a file, keyed by om name.
80
+ *
81
+ * Collected in its own pass rather than alongside the `om(...)` match, because a body is only
82
+ * reachable from the `.run` end of the chain and the om name only from the other — and the
83
+ * chain is walked downward (see {@link chainRoot}), so one pass cannot have both.
84
+ */
85
+ function outlinesIn(sf, omName, imported) {
86
+ const outlines = new Map();
87
+ if (omName === undefined)
88
+ return outlines;
89
+ const visit = (node) => {
90
+ if (ts.isCallExpression(node) &&
91
+ ts.isPropertyAccessExpression(node.expression) &&
92
+ node.expression.name.text === "run") {
93
+ const root = chainRoot(node);
94
+ const body = node.arguments[0];
95
+ const name = root?.call.arguments[0];
96
+ if (root?.name === omName && name && ts.isStringLiteralLike(name) && body) {
97
+ if (ts.isFunctionLike(body))
98
+ outlines.set(name.text, outlineBody(body, imported));
99
+ }
100
+ }
101
+ ts.forEachChild(node, visit);
102
+ };
103
+ visit(sf);
104
+ return outlines;
105
+ }
106
+ /** How long the child gets to import every file before it is killed and reported. */
107
+ const DISCOVER_TIMEOUT_MS = 30_000;
108
+ /**
109
+ * Absolute path of a sibling module, carrying the extension this build actually uses —
110
+ * `.ts` under tsx and vitest, `.js` once compiled. `rewriteRelativeImportExtensions`
111
+ * rewrites import specifiers; this path is data (a fork target), so it does not.
112
+ */
113
+ function siblingScript(name) {
114
+ const here = fileURLToPath(import.meta.url);
115
+ return path.join(path.dirname(here), `${name}${path.extname(here)}`);
116
+ }
117
+ /** Project an om's registration message onto the client-facing shape, folder name and all. */
118
+ function toOmRegistration(m) {
119
+ return {
120
+ name: m.name,
121
+ file: m.file ?? "",
122
+ folderName: `${fsSafe(m.name)}-${omHash(m.name, m.file)}`,
123
+ ...(m.description ? { summary: m.description.summary } : {}),
124
+ ...(m.mcp ? { mcp: m.mcp } : {}),
125
+ ...(m.inputSchema ? { inputSchema: m.inputSchema } : {}),
126
+ ...(m.schemaError ? { unavailable: m.schemaError } : {}),
127
+ };
128
+ }
129
+ /**
130
+ * Import `files` in a throwaway child with `OMKIT_DISCOVER=1` and collect what they
131
+ * declare. Exposed separately from {@link discoverRegistrations} so the fork can be driven
132
+ * with an explicit file list — by tests, and by anything that already knows its candidates.
133
+ *
134
+ * Never rejects for user-code problems: an import failure, a crashed child, or a child
135
+ * that outstays its timeout all come back as warnings on an otherwise usable set.
136
+ */
137
+ export function readRegistrations(files, opts = {}) {
138
+ if (files.length === 0)
139
+ return Promise.resolve({ oms: [], actions: [], warnings: [] });
140
+ const timeoutMs = opts.timeoutMs ?? DISCOVER_TIMEOUT_MS;
141
+ return new Promise((resolve) => {
142
+ const oms = [];
143
+ const warnings = [];
144
+ const child = fork(siblingScript("discover-child"), [], {
145
+ execArgv: ["--import", tsxLoader],
146
+ cwd: opts.cwd ?? path.dirname(files[0]),
147
+ env: { ...process.env, OMKIT_DISCOVER: "1" },
148
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
149
+ });
150
+ let settled = false;
151
+ const finish = (actions) => {
152
+ if (settled)
153
+ return;
154
+ settled = true;
155
+ clearTimeout(timer);
156
+ if (child.exitCode === null && child.signalCode === null)
157
+ child.kill();
158
+ resolve({ oms, actions, warnings });
159
+ };
160
+ const timer = setTimeout(() => {
161
+ warnings.push(`discovery timed out after ${timeoutMs}ms`);
162
+ finish([]);
163
+ }, timeoutMs);
164
+ child.on("message", (message) => {
165
+ const m = message;
166
+ if (m.kind === "om-registration")
167
+ oms.push(toOmRegistration(m));
168
+ else if (m.kind === "actions") {
169
+ warnings.push(...m.warnings);
170
+ finish(m.actions);
171
+ }
172
+ });
173
+ // A child that dies before reporting still yields whatever streamed in before it did.
174
+ child.on("close", () => finish([]));
175
+ child.on("error", (e) => {
176
+ warnings.push(`discovery child failed: ${e.message}`);
177
+ finish([]);
178
+ });
179
+ child.send({ kind: "scan", files });
180
+ });
181
+ }
182
+ /**
183
+ * The full pass: the AST scan picks the candidate files — it stays the only cheap,
184
+ * never-executing phase — then {@link readRegistrations} imports just those. AST warnings
185
+ * are carried through so nothing is lost between the two phases.
186
+ */
187
+ export async function discoverRegistrations(tsconfigPath, opts = {}) {
188
+ const ast = discover(tsconfigPath);
189
+ const files = [...new Set([...ast.oms.map((o) => o.file), ...ast.actions.map((a) => a.file)])];
190
+ const set = await readRegistrations(files, {
191
+ cwd: path.dirname(path.resolve(tsconfigPath)),
192
+ ...opts,
193
+ });
194
+ return { ...set, warnings: [...ast.warnings, ...set.warnings], registry: ast };
195
+ }
59
196
  /**
60
197
  * Load and validate the tsconfig, throwing on a fatal config problem — a missing/unparseable file,
61
198
  * or a config-level error (bad compilerOptions, an unresolvable `extends`). The benign "no inputs
@@ -1,6 +1,7 @@
1
1
  import type { OmkitClient } from "./types.ts";
2
+ export { readRegistrations, discoverRegistrations } from "./discovery.ts";
2
3
  export type { OmkitClient, RunSession, RunOptions, InspectOptions, Verdict, Diagnostic, PromptRequest, RunEvents, } from "./types.ts";
3
- export type { Registry, DiscoveredOm, DiscoveredAction } from "./registry.ts";
4
+ export type { Registry, DiscoveredOm, DiscoveredAction, Registration, OmRegistration, ActionRegistration, RegistrationSet, } from "./registry.ts";
4
5
  import type { InspectOptions } from "./types.ts";
5
6
  /** Configuration for a client — the project's `tsconfig.omkit.json` and optional debugging. */
6
7
  export interface OmkitConfig {
@@ -1,18 +1,28 @@
1
- import { discover } from "./discovery.js";
1
+ import path from "node:path";
2
+ import { discover, discoverRegistrations } from "./discovery.js";
2
3
  import { runOm, spawnBare } from "./runner.js";
3
4
  import { runCheck } from "./check.js";
5
+ export { readRegistrations, discoverRegistrations } from "./discovery.js";
4
6
  /** Build the headless engine: discovery + supervised/bare run + typecheck over one config. */
5
7
  export function createOmkitClient(config) {
6
- // A per-call inspect wins; the client's construction-time inspect is the fallback.
7
- const withInspect = (opts) => ({
8
+ // The project every run of this client belongs to: the directory owning the config it was
9
+ // built from. Injected here rather than at each frontend because "which project is this?" has
10
+ // one answer per client, and three call sites remembering it separately is how the terminal
11
+ // and the MCP server ended up writing their run folders to two different trees.
12
+ const root = path.dirname(path.resolve(config.tsconfig));
13
+ // A per-call inspect wins; the client's construction-time inspect is the fallback. Likewise
14
+ // a caller may name its own root — the injected one is a default, not an override.
15
+ const withDefaults = (opts) => ({
8
16
  ...opts,
9
17
  inspect: opts?.inspect ?? config.inspect,
18
+ env: { OMKIT_ROOT: root, ...opts?.env },
10
19
  });
11
20
  return {
12
21
  tsconfig: config.tsconfig,
13
22
  discover: async () => discover(config.tsconfig),
14
- run: (omFile, opts) => runOm(omFile, withInspect(opts)),
15
- runBare: (omFile, opts) => spawnBare(omFile, withInspect(opts)),
23
+ discoverRegistrations: async () => discoverRegistrations(config.tsconfig),
24
+ run: (omFile, opts) => runOm(omFile, withDefaults(opts)),
25
+ runBare: (omFile, opts) => spawnBare(omFile, withDefaults(opts)),
16
26
  check: async () => runCheck(config.tsconfig),
17
27
  };
18
28
  }
@@ -0,0 +1,35 @@
1
+ import ts from "typescript";
2
+ import type { OmCall } from "./registry.ts";
3
+ /**
4
+ * Every identifier a file binds through an import — the whole allow-list for the outline.
5
+ * An action always arrives by import (`omkit/actions`, or a local actions module), so
6
+ * "was imported" is a cheap stand-in for "is a step" that never needs the type checker.
7
+ */
8
+ export declare function importedNames(sf: ts.SourceFile): ReadonlySet<string>;
9
+ /** A fluent chain resolved down to the call that starts it: `a(…).b().c()` → the `a(…)`. */
10
+ export interface ChainRoot {
11
+ /** The identifier the chain is rooted in. */
12
+ readonly name: string;
13
+ /** The base call itself, so a caller can read its arguments. */
14
+ readonly call: ts.CallExpression;
15
+ /** Every call in the chain, outermost first. */
16
+ readonly calls: readonly ts.CallExpression[];
17
+ }
18
+ /**
19
+ * Walk a fluent chain down from any of its calls to the identifier call at its root.
20
+ *
21
+ * Descends through `.expression` rather than climbing through `.parent`: a `SourceFile` from
22
+ * `ts.createProgram` only has parent pointers once its file has been bound, so climbing would
23
+ * work or not depending on whether the caller happened to ask for diagnostics first.
24
+ */
25
+ export declare function chainRoot(node: ts.CallExpression): ChainRoot | undefined;
26
+ /**
27
+ * A static sketch of what an om body calls: imported identifiers invoked directly inside
28
+ * `.run(body)`, in source order, each paired with the `.tag("…")` from its own fluent chain.
29
+ *
30
+ * Deliberately an approximation, and labelled as one wherever it is rendered. A conditional
31
+ * call appears unconditionally, a loop appears once, an action picked dynamically does not
32
+ * appear at all, and a call made inside a module-level helper is invisible — the walk does
33
+ * not follow indirection. It answers "roughly what happens in here" and nothing stronger.
34
+ */
35
+ export declare function outlineBody(body: ts.Node, imported: ReadonlySet<string>): OmCall[];
@@ -0,0 +1,100 @@
1
+ import ts from "typescript";
2
+ /**
3
+ * Every identifier a file binds through an import — the whole allow-list for the outline.
4
+ * An action always arrives by import (`omkit/actions`, or a local actions module), so
5
+ * "was imported" is a cheap stand-in for "is a step" that never needs the type checker.
6
+ */
7
+ export function importedNames(sf) {
8
+ const names = new Set();
9
+ for (const stmt of sf.statements) {
10
+ if (!ts.isImportDeclaration(stmt) || !stmt.importClause)
11
+ continue;
12
+ const clause = stmt.importClause;
13
+ if (clause.name)
14
+ names.add(clause.name.text);
15
+ const bindings = clause.namedBindings;
16
+ if (!bindings)
17
+ continue;
18
+ if (ts.isNamespaceImport(bindings))
19
+ names.add(bindings.name.text);
20
+ else
21
+ for (const el of bindings.elements)
22
+ names.add(el.name.text);
23
+ }
24
+ return names;
25
+ }
26
+ /**
27
+ * Walk a fluent chain down from any of its calls to the identifier call at its root.
28
+ *
29
+ * Descends through `.expression` rather than climbing through `.parent`: a `SourceFile` from
30
+ * `ts.createProgram` only has parent pointers once its file has been bound, so climbing would
31
+ * work or not depending on whether the caller happened to ask for diagnostics first.
32
+ */
33
+ export function chainRoot(node) {
34
+ const calls = [];
35
+ let current = node;
36
+ while (ts.isCallExpression(current)) {
37
+ calls.push(current);
38
+ const callee = current.expression;
39
+ if (ts.isPropertyAccessExpression(callee)) {
40
+ current = callee.expression;
41
+ continue;
42
+ }
43
+ if (ts.isIdentifier(callee))
44
+ return { name: callee.text, call: current, calls };
45
+ return undefined;
46
+ }
47
+ return undefined;
48
+ }
49
+ /**
50
+ * A static sketch of what an om body calls: imported identifiers invoked directly inside
51
+ * `.run(body)`, in source order, each paired with the `.tag("…")` from its own fluent chain.
52
+ *
53
+ * Deliberately an approximation, and labelled as one wherever it is rendered. A conditional
54
+ * call appears unconditionally, a loop appears once, an action picked dynamically does not
55
+ * appear at all, and a call made inside a module-level helper is invisible — the walk does
56
+ * not follow indirection. It answers "roughly what happens in here" and nothing stronger.
57
+ */
58
+ export function outlineBody(body, imported) {
59
+ const calls = [];
60
+ const seen = new Set();
61
+ const visit = (node) => {
62
+ const chain = ts.isCallExpression(node) ? chainRoot(node) : undefined;
63
+ if (chain && imported.has(chain.name)) {
64
+ const tag = tagOf(chain);
65
+ // Name and tag together are the identity: three `command` calls with distinct tags are
66
+ // three steps, while the same call written twice is one.
67
+ const key = `${chain.name} ${tag ?? ""}`;
68
+ if (!seen.has(key)) {
69
+ seen.add(key);
70
+ calls.push(tag === undefined ? { name: chain.name } : { name: chain.name, tag });
71
+ }
72
+ // The chain's own spine is now accounted for; only its arguments can hold further steps.
73
+ // Walking it again would re-enter each inner call and record the same step repeatedly.
74
+ for (const call of chain.calls)
75
+ for (const arg of call.arguments)
76
+ visit(arg);
77
+ return;
78
+ }
79
+ ts.forEachChild(node, visit);
80
+ };
81
+ visit(body);
82
+ return calls;
83
+ }
84
+ /**
85
+ * The `.tag("…")` on a chain, taken nearest its root. `calls` is outermost-first, so the last
86
+ * match is the innermost — the tag attached directly to the action rather than to something
87
+ * further along the chain.
88
+ */
89
+ function tagOf(chain) {
90
+ let tag;
91
+ for (const call of chain.calls) {
92
+ const callee = call.expression;
93
+ if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "tag")
94
+ continue;
95
+ const arg = call.arguments[0];
96
+ if (arg && ts.isStringLiteralLike(arg))
97
+ tag = arg.text;
98
+ }
99
+ return tag;
100
+ }