oh-my-workflow 0.2.0 → 0.4.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/README.md +191 -106
- package/conformance/budget-loop.ts +16 -0
- package/conformance/fanout.ts +20 -0
- package/conformance/pipeline.ts +21 -0
- package/conformance/schema-gate.ts +21 -0
- package/conformance/strict-throws.ts +13 -0
- package/docs/launch/show-hn.md +41 -0
- package/docs/site/index.html +540 -0
- package/docs/site/robots.txt +2 -0
- package/examples/deep-research/workflow.ts +11 -11
- package/package.json +11 -3
- package/scripts/build-docs.ts +10 -0
- package/scripts/check-docs.ts +58 -0
- package/skill/SKILL.md +247 -137
- package/src/adapters/claude.ts +31 -5
- package/src/adapters/codex.ts +5 -3
- package/src/adapters/exec.ts +103 -0
- package/src/adapters/fake.ts +4 -4
- package/src/adapters/hermes.ts +24 -0
- package/src/adapters/types.ts +33 -3
- package/src/cli/codemod.ts +99 -0
- package/src/cli/omw.ts +7 -2
- package/src/cli/run.ts +222 -13
- package/src/cli/skill.ts +32 -10
- package/src/runtime.ts +171 -11
- package/src/worktree.ts +72 -0
- package/vercel.json +5 -0
package/src/cli/run.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
// `omw run <wf> --agent <a> [--args JSON] [--concurrency N] [--pretty]`.
|
|
1
|
+
// `omw run <wf> [--agent <a>] [--args JSON] [--concurrency N] [--pretty]`.
|
|
2
2
|
// Parsing is a pure function so the input contract is testable without touching
|
|
3
3
|
// the filesystem, a clock, or a subprocess.
|
|
4
4
|
|
|
5
5
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
6
7
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
8
9
|
import type { AgentPort } from "../adapters/types";
|
|
9
10
|
import { makeFakeAdapter, type FakeAdapterOptions } from "../adapters/fake";
|
|
10
11
|
import { makeClaudeAdapter } from "../adapters/claude";
|
|
11
12
|
import { makeCodexAdapter } from "../adapters/codex";
|
|
12
|
-
import
|
|
13
|
+
import { makeHermesAdapter } from "../adapters/hermes";
|
|
14
|
+
import type { Runtime, WorkflowMeta } from "../runtime";
|
|
13
15
|
import { makeRuntime } from "../runtime";
|
|
14
16
|
import { makeJournal, parseJournalLines, type JournalEvent } from "../journal";
|
|
15
17
|
import type { ResumeIndex } from "../resume";
|
|
@@ -23,6 +25,12 @@ export type RunOptions = {
|
|
|
23
25
|
pretty: boolean;
|
|
24
26
|
/** Path to a prior run's journal to resume from (longest-unchanged-prefix). */
|
|
25
27
|
resume?: string;
|
|
28
|
+
/** Opt-in determinism sandbox: forbid Date/Math.random in the script body so a
|
|
29
|
+
* run is reproducible (matches native dynamic-workflow's freeze-throw). */
|
|
30
|
+
strict?: boolean;
|
|
31
|
+
/** Token ceiling for the whole run; agent() throws BudgetExceededError once the
|
|
32
|
+
* shared spend reaches it. */
|
|
33
|
+
budget?: number;
|
|
26
34
|
};
|
|
27
35
|
|
|
28
36
|
export type ParseResult =
|
|
@@ -36,6 +44,8 @@ export function parseRunArgs(argv: string[]): ParseResult {
|
|
|
36
44
|
let concurrency: number | undefined;
|
|
37
45
|
let pretty = false;
|
|
38
46
|
let resume: string | undefined;
|
|
47
|
+
let budget: number | undefined;
|
|
48
|
+
let strict = false;
|
|
39
49
|
|
|
40
50
|
for (let i = 0; i < argv.length; i++) {
|
|
41
51
|
const tok = argv[i]!;
|
|
@@ -61,9 +71,21 @@ export function parseRunArgs(argv: string[]): ParseResult {
|
|
|
61
71
|
concurrency = n;
|
|
62
72
|
break;
|
|
63
73
|
}
|
|
74
|
+
case "--budget": {
|
|
75
|
+
const raw = argv[++i];
|
|
76
|
+
const n = Number(raw);
|
|
77
|
+
if (raw === undefined || !Number.isInteger(n) || n < 1) {
|
|
78
|
+
return { ok: false, error: `--budget must be a positive integer, got: ${raw}` };
|
|
79
|
+
}
|
|
80
|
+
budget = n;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
64
83
|
case "--pretty":
|
|
65
84
|
pretty = true;
|
|
66
85
|
break;
|
|
86
|
+
case "--strict":
|
|
87
|
+
strict = true;
|
|
88
|
+
break;
|
|
67
89
|
case "--resume":
|
|
68
90
|
resume = argv[++i];
|
|
69
91
|
if (!resume) return { ok: false, error: "--resume requires a journal path" };
|
|
@@ -75,9 +97,8 @@ export function parseRunArgs(argv: string[]): ParseResult {
|
|
|
75
97
|
}
|
|
76
98
|
|
|
77
99
|
if (wfPath === undefined) return { ok: false, error: "missing workflow path" };
|
|
78
|
-
if (agent === undefined) return { ok: false, error: "missing --agent <name>" };
|
|
79
100
|
|
|
80
|
-
return { ok: true, value: { wfPath, agent, args, concurrency, pretty, resume } };
|
|
101
|
+
return { ok: true, value: { wfPath, agent: agent ?? "auto", args, concurrency, pretty, resume, budget, strict } };
|
|
81
102
|
}
|
|
82
103
|
|
|
83
104
|
// ── workflow execution ──────────────────────────────────────────────────────
|
|
@@ -88,6 +109,8 @@ export function parseRunArgs(argv: string[]): ParseResult {
|
|
|
88
109
|
export type LoadedWorkflow = {
|
|
89
110
|
workflow: (rt: Runtime, args: unknown) => unknown | Promise<unknown>;
|
|
90
111
|
fake?: FakeAdapterOptions;
|
|
112
|
+
/** Optional `export const meta` describing the workflow (name/phases/model). */
|
|
113
|
+
meta?: WorkflowMeta;
|
|
91
114
|
};
|
|
92
115
|
|
|
93
116
|
/** Either a ready adapter, or a structured "not installed" signal (exit 3). */
|
|
@@ -145,9 +168,11 @@ export async function loadWorkflow(wfPath: string): Promise<LoadedWorkflow> {
|
|
|
145
168
|
|
|
146
169
|
const mod = await import(entry);
|
|
147
170
|
if (typeof mod.default !== "function") {
|
|
148
|
-
throw new Error(
|
|
171
|
+
throw new Error(
|
|
172
|
+
`workflow ${wfPath} must default-export a function ({ agent, parallel, pipeline, phase, log, workflow, budget }, args) => result (legacy (rt, args) still supported)`,
|
|
173
|
+
);
|
|
149
174
|
}
|
|
150
|
-
return { workflow: mod.default, fake: mod.fake };
|
|
175
|
+
return { workflow: mod.default, fake: mod.fake, meta: mod.meta };
|
|
151
176
|
}
|
|
152
177
|
|
|
153
178
|
export type RunDeps = {
|
|
@@ -175,6 +200,82 @@ export type RunOutcome = {
|
|
|
175
200
|
|
|
176
201
|
const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
|
177
202
|
|
|
203
|
+
/** True when a workflow's first param is NOT an object-destructuring pattern,
|
|
204
|
+
* i.e. the legacy positional `(rt, args)` shape. Used only to emit a
|
|
205
|
+
* deprecation nudge — never to dispatch, since the same object satisfies both
|
|
206
|
+
* contracts. A source sniff via Function.prototype.toString; heuristic by
|
|
207
|
+
* nature (it can't see through a bound/wrapped fn), but a non-fatal warning is
|
|
208
|
+
* the right altitude for a heuristic. */
|
|
209
|
+
function isLegacyShape(fn: Function): boolean {
|
|
210
|
+
const src = Function.prototype.toString.call(fn);
|
|
211
|
+
// New shape = first param is an object-destructuring pattern. Allow an optional
|
|
212
|
+
// function NAME between `function` and `(` (e.g. `function deepResearch({…})`),
|
|
213
|
+
// else a named destructured default export is misflagged as legacy.
|
|
214
|
+
return !/^\s*(async\s+)?function\s*\*?\s*[A-Za-z0-9_$]*\s*\(\s*\{|^\s*\(\s*\{|^\s*async\s*\(\s*\{/.test(src);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Run `fn` with Date/Math.random frozen to throw, restoring them after (even on
|
|
218
|
+
* throw). Opt-in determinism: a `--strict` run fails loudly if the script reaches
|
|
219
|
+
* for wall-clock or randomness, which would make a journal non-reproducible.
|
|
220
|
+
* Scoped to the invoke and restored in finally — the engine's injected clock is
|
|
221
|
+
* untouched, so journaling/resume still work. Mirrors native's freeze-throw. */
|
|
222
|
+
// Reentrancy state for withStrict. The patch touches PROCESS-GLOBAL Date/
|
|
223
|
+
// Math.random, so overlapping strict runs (nested workflow() or concurrent
|
|
224
|
+
// runWorkflow callers in one process) must share a SINGLE install and restore
|
|
225
|
+
// only when the last one unwinds. A naive per-call save/restore races: a second
|
|
226
|
+
// caller would snapshot the already-patched StrictDate as its "original" and
|
|
227
|
+
// restore it back, leaving the throwing clock installed forever. Depth-counting
|
|
228
|
+
// with the true original captured once (at depth 0) closes that.
|
|
229
|
+
let strictDepth = 0;
|
|
230
|
+
let strictSavedDate: DateConstructor;
|
|
231
|
+
let strictSavedRandom: () => number;
|
|
232
|
+
|
|
233
|
+
async function withStrict<T>(strict: boolean | undefined, fn: () => T | Promise<T>): Promise<T> {
|
|
234
|
+
if (!strict) return await fn();
|
|
235
|
+
const entering = strictDepth === 0;
|
|
236
|
+
if (entering) {
|
|
237
|
+
// Snapshot the TRUE originals before any patch (a plain var write, can't throw).
|
|
238
|
+
strictSavedDate = globalThis.Date;
|
|
239
|
+
strictSavedRandom = Math.random;
|
|
240
|
+
}
|
|
241
|
+
// Increment and enter the try BEFORE patching, so a throw mid-patch (e.g. a
|
|
242
|
+
// frozen global) still hits finally and restores — no leaked StrictDate.
|
|
243
|
+
strictDepth++;
|
|
244
|
+
try {
|
|
245
|
+
if (entering) {
|
|
246
|
+
const boom = (what: string): never => {
|
|
247
|
+
throw new Error(`omw --strict: ${what} is forbidden in a deterministic workflow (pass values in via args)`);
|
|
248
|
+
};
|
|
249
|
+
class StrictDate extends strictSavedDate {
|
|
250
|
+
constructor(...args: any[]) {
|
|
251
|
+
if (args.length === 0) boom("new Date()");
|
|
252
|
+
super(...(args as [number]));
|
|
253
|
+
}
|
|
254
|
+
static now(): number {
|
|
255
|
+
return boom("Date.now()");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
globalThis.Date = StrictDate as DateConstructor;
|
|
259
|
+
Math.random = () => boom("Math.random()");
|
|
260
|
+
}
|
|
261
|
+
return await fn();
|
|
262
|
+
} finally {
|
|
263
|
+
strictDepth--;
|
|
264
|
+
if (strictDepth === 0) {
|
|
265
|
+
// Restore each global independently: if one assignment throws (the global
|
|
266
|
+
// was frozen mid-run — the same hostile env this guards against), the other
|
|
267
|
+
// must still be restored, or a throwing patch leaks process-wide. Swallow
|
|
268
|
+
// the restore error so it can't mask fn()'s real result/error either.
|
|
269
|
+
try {
|
|
270
|
+
globalThis.Date = strictSavedDate;
|
|
271
|
+
} catch {}
|
|
272
|
+
try {
|
|
273
|
+
Math.random = strictSavedRandom;
|
|
274
|
+
} catch {}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
178
279
|
export async function runWorkflow(opts: RunOptions, deps: RunDeps): Promise<RunOutcome> {
|
|
179
280
|
let loaded: LoadedWorkflow;
|
|
180
281
|
try {
|
|
@@ -193,11 +294,54 @@ export async function runWorkflow(opts: RunOptions, deps: RunDeps): Promise<RunO
|
|
|
193
294
|
|
|
194
295
|
const runId = deps.runId();
|
|
195
296
|
const journal = makeJournal({ sink: deps.journalSink, now: deps.now });
|
|
196
|
-
|
|
297
|
+
// One spend accumulator for the whole run: parent + any nested workflow()
|
|
298
|
+
// child point at it, so the token pool is shared (matches native).
|
|
299
|
+
const budgetState = { spent: 0 };
|
|
300
|
+
const rt = makeRuntime({
|
|
301
|
+
adapter: resolved.adapter,
|
|
302
|
+
journal,
|
|
303
|
+
concurrency: opts.concurrency,
|
|
304
|
+
resume: deps.resume,
|
|
305
|
+
budget: opts.budget,
|
|
306
|
+
budgetState,
|
|
307
|
+
meta: loaded.meta,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// workflow(ref, args?) runs another workflow inline as a sub-step, sharing the
|
|
311
|
+
// resolved adapter + journal + spend pool. One level only: a workflow() inside
|
|
312
|
+
// a child throws, so a runaway recursion can't hide behind the null-contract.
|
|
313
|
+
const makeWorkflowHook = (depth: number) =>
|
|
314
|
+
async (ref: string | { scriptPath: string }, childArgs?: unknown): Promise<unknown> => {
|
|
315
|
+
if (depth >= 1) throw new Error("workflow() nesting is one level only");
|
|
316
|
+
const childPath = typeof ref === "string" ? ref : ref.scriptPath;
|
|
317
|
+
const childLoaded = await deps.loadWorkflow(childPath);
|
|
318
|
+
const childRt = makeRuntime({
|
|
319
|
+
adapter: resolved.adapter,
|
|
320
|
+
journal,
|
|
321
|
+
concurrency: opts.concurrency,
|
|
322
|
+
resume: deps.resume,
|
|
323
|
+
budget: opts.budget,
|
|
324
|
+
budgetState,
|
|
325
|
+
meta: childLoaded.meta,
|
|
326
|
+
});
|
|
327
|
+
const childHooks = { ...childRt, workflow: makeWorkflowHook(depth + 1) };
|
|
328
|
+
return await childLoaded.workflow(childHooks as unknown as Runtime, childArgs);
|
|
329
|
+
};
|
|
197
330
|
|
|
198
331
|
journal.runStart({ run: runId, wf: opts.wfPath });
|
|
199
332
|
try {
|
|
200
|
-
|
|
333
|
+
// The SAME runtime object satisfies both authoring contracts: a legacy
|
|
334
|
+
// `(rt, args)` script reads `rt.agent`, a new `({ agent }, args)` script
|
|
335
|
+
// destructures it. No execution-time dispatch — only the deprecation nudge
|
|
336
|
+
// needs to detect the legacy positional shape. `workflow` is layered on here
|
|
337
|
+
// (not in makeRuntime) since nesting needs the loader + resolved adapter.
|
|
338
|
+
const hooks = { ...rt, workflow: makeWorkflowHook(0) };
|
|
339
|
+
if (isLegacyShape(loaded.workflow)) {
|
|
340
|
+
deps.stderr?.(
|
|
341
|
+
"omw: deprecation — positional `rt` authoring is deprecated; destructure hooks `({ agent, ... }, args)`. Removed in 0.5. Run `omw codemod`.\n",
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
const result = await withStrict(opts.strict, () => loaded.workflow(hooks, opts.args));
|
|
201
345
|
// internal_error is an AUTHOR bug (e.g. a JSON Schema that won't compile),
|
|
202
346
|
// not a flaky node — the null-contract absorbs it so the run completes, but
|
|
203
347
|
// we escalate to exit 4 so a caller (or authoring agent) doesn't read the
|
|
@@ -234,18 +378,72 @@ export async function runWorkflow(opts: RunOptions, deps: RunDeps): Promise<RunO
|
|
|
234
378
|
const INSTALL_HINTS: Record<string, string> = {
|
|
235
379
|
claude: "npm i -g @anthropic-ai/claude-code (then `claude login`)",
|
|
236
380
|
codex: "npm i -g @openai/codex (experimental adapter)",
|
|
381
|
+
hermes: "install the Hermes Agent CLI, then `hermes login` (experimental adapter)",
|
|
237
382
|
pi: "see https://github.com/parallel-ai/pi (experimental adapter)",
|
|
238
383
|
};
|
|
239
384
|
|
|
240
385
|
/** PATH probe — injected so the missing→installed branch is testable. */
|
|
241
386
|
const defaultBinExists = (bin: string): boolean => Bun.which(bin) != null;
|
|
387
|
+
const AUTO_ADAPTERS = ["claude", "codex", "hermes"] as const;
|
|
388
|
+
|
|
389
|
+
function unique<T>(items: T[]): T[] {
|
|
390
|
+
return [...new Set(items)];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function autoCandidates(env: Record<string, string | undefined>): string[] {
|
|
394
|
+
const explicit = env.OMW_AGENT?.trim().toLowerCase();
|
|
395
|
+
if (explicit && explicit !== "auto") return [explicit];
|
|
396
|
+
|
|
397
|
+
const keys = new Set(Object.keys(env).map((k) => k.toUpperCase()));
|
|
398
|
+
const hostHints: string[] = [];
|
|
399
|
+
if (keys.has("CLAUDECODE") || keys.has("CLAUDE_CODE") || keys.has("CLAUDE_CODE_ENTRYPOINT")) {
|
|
400
|
+
hostHints.push("claude");
|
|
401
|
+
}
|
|
402
|
+
if (keys.has("CODEX_SANDBOX") || keys.has("CODEX_CLI") || keys.has("OPENAI_CODEX")) {
|
|
403
|
+
hostHints.push("codex");
|
|
404
|
+
}
|
|
405
|
+
if (keys.has("HERMES") || keys.has("HERMES_CLI")) {
|
|
406
|
+
hostHints.push("hermes");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return unique([...hostHints, ...AUTO_ADAPTERS]);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function makeNamedAdapter(name: string, wf: LoadedWorkflow): AgentPort | undefined {
|
|
413
|
+
if (name === "fake") return makeFakeAdapter(wf.fake);
|
|
414
|
+
if (name === "claude") return makeClaudeAdapter();
|
|
415
|
+
if (name === "codex") return makeCodexAdapter();
|
|
416
|
+
if (name === "hermes") return makeHermesAdapter();
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
242
419
|
|
|
243
420
|
export function resolveAdapter(
|
|
244
421
|
name: string,
|
|
245
422
|
wf: LoadedWorkflow,
|
|
246
423
|
binExists: (bin: string) => boolean = defaultBinExists,
|
|
424
|
+
env: Record<string, string | undefined> = process.env,
|
|
247
425
|
): AdapterResolution {
|
|
248
426
|
if (name === "fake") return { adapter: makeFakeAdapter(wf.fake) };
|
|
427
|
+
if (name === "auto") {
|
|
428
|
+
const candidates = autoCandidates(env);
|
|
429
|
+
for (const candidate of candidates) {
|
|
430
|
+
const adapter = makeNamedAdapter(candidate, wf);
|
|
431
|
+
if (candidate === "fake" && adapter) return { adapter };
|
|
432
|
+
if (adapter && binExists(candidate)) return { adapter };
|
|
433
|
+
}
|
|
434
|
+
const explicit = env.OMW_AGENT?.trim().toLowerCase();
|
|
435
|
+
if (explicit && explicit !== "auto") {
|
|
436
|
+
return {
|
|
437
|
+
missing: explicit,
|
|
438
|
+
installHint: INSTALL_HINTS[explicit] ?? `unknown adapter "${explicit}". Set OMW_AGENT to claude, codex, or hermes.`,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
missing: "auto",
|
|
443
|
+
installHint:
|
|
444
|
+
"could not auto-detect an installed coding-agent CLI. Install claude/codex/hermes, set OMW_AGENT=claude|codex|hermes, or pass --agent fake for the no-key demo.",
|
|
445
|
+
};
|
|
446
|
+
}
|
|
249
447
|
if (name === "claude") {
|
|
250
448
|
// A real adapter exists, but exit 3 (adapter_missing) if the CLI isn't on
|
|
251
449
|
// PATH — tell the user what to install rather than failing mid-run.
|
|
@@ -256,6 +454,10 @@ export function resolveAdapter(
|
|
|
256
454
|
if (!binExists("codex")) return { missing: "codex", installHint: INSTALL_HINTS.codex! };
|
|
257
455
|
return { adapter: makeCodexAdapter() };
|
|
258
456
|
}
|
|
457
|
+
if (name === "hermes") {
|
|
458
|
+
if (!binExists("hermes")) return { missing: "hermes", installHint: INSTALL_HINTS.hermes! };
|
|
459
|
+
return { adapter: makeHermesAdapter() };
|
|
460
|
+
}
|
|
259
461
|
// pi lands here as it is built; until then, fail actionably.
|
|
260
462
|
return {
|
|
261
463
|
missing: name,
|
|
@@ -273,7 +475,7 @@ export type Io = {
|
|
|
273
475
|
runId?: () => string;
|
|
274
476
|
};
|
|
275
477
|
|
|
276
|
-
const defaultRunId = (): string =>
|
|
478
|
+
const defaultRunId = (): string => `r-${Date.now().toString(36)}-${process.pid.toString(36)}-${randomUUID().slice(0, 8)}`;
|
|
277
479
|
|
|
278
480
|
/** Wire real fs/import deps and run. Returns the exit code; writes the result
|
|
279
481
|
* JSON to stdout, the journal to <omwDir>/<runId>.jsonl, and any error JSON to
|
|
@@ -283,7 +485,7 @@ export async function runCommand(argv: string[], io: Io): Promise<number> {
|
|
|
283
485
|
if (!parsed.ok) {
|
|
284
486
|
io.stderr(JSON.stringify({ error: "usage", message: parsed.error }));
|
|
285
487
|
io.stderr(
|
|
286
|
-
"\nusage: omw run <workflow> --agent <fake|claude|codex|pi> [--args JSON] [--concurrency N] [--resume <journal
|
|
488
|
+
"\nusage: omw run <workflow> [--agent <auto|fake|claude|codex|hermes|pi>] [--args JSON] [--concurrency N] [--budget N] [--resume <journal|runId>] [--strict] [--pretty]",
|
|
287
489
|
);
|
|
288
490
|
return 2;
|
|
289
491
|
}
|
|
@@ -296,11 +498,17 @@ export async function runCommand(argv: string[], io: Io): Promise<number> {
|
|
|
296
498
|
// unreadable --resume path is a user error, not a reason to silently run live).
|
|
297
499
|
let resume: ResumeIndex | undefined;
|
|
298
500
|
if (parsed.value.resume) {
|
|
501
|
+
// Accept either a journal path or a bare runId: if the arg isn't an existing
|
|
502
|
+
// file, treat it as a runId and resolve <omwDir>/<runId>.jsonl (the path the
|
|
503
|
+
// run wrote its journal to). Lets `--resume <runId>` mirror the runId printed
|
|
504
|
+
// on the prior run without the caller reconstructing the path.
|
|
505
|
+
const resumeArg = parsed.value.resume;
|
|
506
|
+
const resumePath = existsSync(resumeArg) ? resumeArg : join(omwDir, `${resumeArg}.jsonl`);
|
|
299
507
|
let lines: string[];
|
|
300
508
|
try {
|
|
301
|
-
lines = readFileSync(
|
|
509
|
+
lines = readFileSync(resumePath, "utf8").split("\n");
|
|
302
510
|
} catch {
|
|
303
|
-
io.stderr(JSON.stringify({ error: "resume_read_failed", path:
|
|
511
|
+
io.stderr(JSON.stringify({ error: "resume_read_failed", path: resumePath }) + "\n");
|
|
304
512
|
return 1;
|
|
305
513
|
}
|
|
306
514
|
resume = makeResumeIndexFromLines(lines);
|
|
@@ -308,7 +516,7 @@ export async function runCommand(argv: string[], io: Io): Promise<number> {
|
|
|
308
516
|
// Readable but no cached nodes (empty/truncated/wrong file). Warn instead
|
|
309
517
|
// of silently re-running every node live — which the user would read as a
|
|
310
518
|
// free resume while paying full adapter cost.
|
|
311
|
-
io.stderr(JSON.stringify({ warning: "resume_empty", path:
|
|
519
|
+
io.stderr(JSON.stringify({ warning: "resume_empty", path: resumePath }) + "\n");
|
|
312
520
|
}
|
|
313
521
|
}
|
|
314
522
|
|
|
@@ -326,6 +534,7 @@ export async function runCommand(argv: string[], io: Io): Promise<number> {
|
|
|
326
534
|
now: () => Date.now(),
|
|
327
535
|
runId: () => runId,
|
|
328
536
|
resume,
|
|
537
|
+
stderr: io.stderr, // surface the legacy-authoring deprecation nudge to the user
|
|
329
538
|
});
|
|
330
539
|
|
|
331
540
|
if (outcome.stdout !== undefined) io.stdout(outcome.stdout + "\n");
|
package/src/cli/skill.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url";
|
|
|
15
15
|
/** Package root, so the bundled skill resolves whether omw runs from a clone or
|
|
16
16
|
* an npm install invoked from any cwd. (Same technique as run.ts's PKG_ROOT.) */
|
|
17
17
|
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
18
|
-
const SKILL_NAME = "
|
|
18
|
+
const SKILL_NAME = "omw";
|
|
19
19
|
|
|
20
20
|
export type SkillIo = {
|
|
21
21
|
stdout: (s: string) => void;
|
|
@@ -27,8 +27,10 @@ export type SkillIo = {
|
|
|
27
27
|
skillDir?: string;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
export type SkillAgent = "claude" | "codex" | "opencode";
|
|
31
|
+
|
|
30
32
|
export type SkillParse =
|
|
31
|
-
| { ok: true; sub: "install"; project: boolean }
|
|
33
|
+
| { ok: true; sub: "install"; project: boolean; agent: SkillAgent }
|
|
32
34
|
| { ok: true; sub: "path" }
|
|
33
35
|
| { ok: true; sub: "help" }
|
|
34
36
|
| { ok: false; error: string };
|
|
@@ -36,8 +38,11 @@ export type SkillParse =
|
|
|
36
38
|
const USAGE =
|
|
37
39
|
"usage: omw skill <command>\n\n" +
|
|
38
40
|
"commands:\n" +
|
|
39
|
-
" install [--project]
|
|
40
|
-
"
|
|
41
|
+
" install [--project] [--codex|--opencode]\n" +
|
|
42
|
+
" copy the skill into a coding agent's skills dir so it's picked up\n" +
|
|
43
|
+
" (default agent: claude → ~/.claude/skills/omw;\n" +
|
|
44
|
+
" --codex → ~/.codex/skills/…; --opencode → ~/.config/opencode/skills/…;\n" +
|
|
45
|
+
" --project targets the cwd instead of home)\n" +
|
|
41
46
|
" path print the bundled SKILL.md path (for cat / piping / pointing an agent at it)\n";
|
|
42
47
|
|
|
43
48
|
export function parseSkillArgs(argv: string[]): SkillParse {
|
|
@@ -51,15 +56,31 @@ export function parseSkillArgs(argv: string[]): SkillParse {
|
|
|
51
56
|
}
|
|
52
57
|
if (sub === "install") {
|
|
53
58
|
let project = false;
|
|
59
|
+
let agent: SkillAgent = "claude";
|
|
54
60
|
for (const tok of rest) {
|
|
55
61
|
if (tok === "--project") project = true;
|
|
62
|
+
else if (tok === "--codex") agent = "codex";
|
|
63
|
+
else if (tok === "--opencode") agent = "opencode";
|
|
56
64
|
else return { ok: false, error: `unexpected argument: ${tok}` };
|
|
57
65
|
}
|
|
58
|
-
return { ok: true, sub: "install", project };
|
|
66
|
+
return { ok: true, sub: "install", project, agent };
|
|
59
67
|
}
|
|
60
68
|
return { ok: false, error: `unknown skill subcommand: ${sub}` };
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
/** Per-agent destination for the skill, each a DISTINCT dir so a clean-replace
|
|
72
|
+
* install never wipes a sibling agent's copy. */
|
|
73
|
+
function skillDest(agent: SkillAgent, root: string): { destDir: string; discovers: string } {
|
|
74
|
+
switch (agent) {
|
|
75
|
+
case "codex":
|
|
76
|
+
return { destDir: join(root, ".codex", "skills", SKILL_NAME), discovers: "Codex" };
|
|
77
|
+
case "opencode":
|
|
78
|
+
return { destDir: join(root, ".config", "opencode", "skills", SKILL_NAME), discovers: "opencode" };
|
|
79
|
+
case "claude":
|
|
80
|
+
return { destDir: join(root, ".claude", "skills", SKILL_NAME), discovers: "Claude Code" };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
63
84
|
export async function skillCommand(argv: string[], io: SkillIo): Promise<number> {
|
|
64
85
|
const parsed = parseSkillArgs(argv);
|
|
65
86
|
if (!parsed.ok) {
|
|
@@ -84,9 +105,10 @@ export async function skillCommand(argv: string[], io: SkillIo): Promise<number>
|
|
|
84
105
|
}
|
|
85
106
|
|
|
86
107
|
// install — idempotent: copy the whole skill dir (SKILL.md + any bundled
|
|
87
|
-
// resources) in place, and report installed vs updated.
|
|
88
|
-
|
|
89
|
-
const
|
|
108
|
+
// resources) in place, and report installed vs updated. The destination is
|
|
109
|
+
// per-agent and DISTINCT, so the clean-replace below never wipes a sibling.
|
|
110
|
+
const root = parsed.project ? (io.cwd ?? process.cwd()) : (io.homeDir ?? homedir());
|
|
111
|
+
const { destDir, discovers } = skillDest(parsed.agent, root);
|
|
90
112
|
const dest = join(destDir, "SKILL.md");
|
|
91
113
|
const updating = existsSync(dest);
|
|
92
114
|
// Clean replace, not an additive copy: drop a prior install first so a file
|
|
@@ -97,8 +119,8 @@ export async function skillCommand(argv: string[], io: SkillIo): Promise<number>
|
|
|
97
119
|
|
|
98
120
|
io.stdout(
|
|
99
121
|
`${updating ? "updated" : "installed"} ${SKILL_NAME} skill → ${dest}\n` +
|
|
100
|
-
`${parsed.project ? "This project's" :
|
|
101
|
-
`Next: ask your coding agent
|
|
122
|
+
`${parsed.project ? "This project's" : discovers} agent auto-discovers skills here.\n` +
|
|
123
|
+
`Next: ask your coding agent with "/omw <your task>".\n`,
|
|
102
124
|
);
|
|
103
125
|
return 0;
|
|
104
126
|
}
|