oh-my-workflow 0.2.1 → 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/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 type { Runtime } from "../runtime";
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(`workflow ${wfPath} must default-export a function (rt, args) => result`);
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
- const rt = makeRuntime({ adapter: resolved.adapter, journal, concurrency: opts.concurrency, resume: deps.resume });
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
- const result = await loaded.workflow(rt, opts.args);
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 => "r-" + Date.now().toString(36);
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.jsonl>] [--pretty]",
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(parsed.value.resume, "utf8").split("\n");
509
+ lines = readFileSync(resumePath, "utf8").split("\n");
302
510
  } catch {
303
- io.stderr(JSON.stringify({ error: "resume_read_failed", path: parsed.value.resume }) + "\n");
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: parsed.value.resume }) + "\n");
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 = "oh-my-workflow";
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] copy the skill into a skills dir so a coding agent picks it up\n" +
40
- " (default: ~/.claude/skills/oh-my-workflow; --project: ./.claude/skills/…)\n" +
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
- const base = parsed.project ? join(io.cwd ?? process.cwd(), ".claude") : join(io.homeDir ?? homedir(), ".claude");
89
- const destDir = join(base, "skills", SKILL_NAME);
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" : "Claude Code"} agent auto-discovers skills here.\n` +
101
- `Next: ask your coding agent to "use oh-my-workflow to <your task>".\n`,
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
  }