wdi-method 0.6.31 → 0.6.32

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/bin/wdi-method.js CHANGED
@@ -20,13 +20,23 @@ import {
20
20
  writeProductIdentity,
21
21
  } from "../lib/identity.mjs";
22
22
  import {
23
+ declaredPlatforms,
23
24
  detectPlatforms,
24
25
  formatPlatformList,
26
+ getPlatform,
27
+ hostsYaml,
25
28
  isKnownPlatform,
29
+ isRetiredPlatform,
30
+ normalizePlatformId,
26
31
  normalizePlatformIds,
32
+ PLATFORMS,
27
33
  platformSelectOptions,
34
+ platformsWithManualOnly,
28
35
  platformUsesHook,
29
36
  PREFERRED_PLATFORM_IDS,
37
+ RETIRED_PLATFORMS,
38
+ retiredPlatformsIn,
39
+ ruleFiles,
30
40
  skillDestinations,
31
41
  } from "../lib/platforms.mjs";
32
42
  import { opencodeCommandsDir, syncOpencodeCommands } from "../lib/opencode-commands.mjs";
@@ -101,9 +111,36 @@ const GUARD_LINE = `> **${GUARD_MARK}.** \`wdi-method\` unlocked model invocatio
101
111
  + "session, a subagent that thought this looked relevant — stop and say so: this engine publishes "
102
112
  + "to the tracker and writes code.";
103
113
 
104
- // Every folder a platform reads skills from. One list, because a repo installs the engines wherever
105
- // `npx skills add` was pointed, and that installer offers symlinks across several of them.
106
- const SKILL_HOMES = [".claude", ".agents", ".agent", ".cursor", ".codex"];
114
+ // The method's own skills a person MUST type. Each carries `disable-model-invocation: true` (the hosts
115
+ // that honour it hold it there), a guard line at the top of its body (every host reads that), and a
116
+ // line in the AGENTS.md block (every host reads that too).
117
+ const WDI_MANUAL_ONLY = [
118
+ "wdi-daily-what-to-build",
119
+ "wdi-daily-autopilot",
120
+ "wdi-daily-what-to-test",
121
+ "wdi-prune-or-archive",
122
+ "wdi-explain-to-me",
123
+ ];
124
+
125
+ /** Every folder in a repo that may hold skills.
126
+ *
127
+ * NOT a hand-kept list of hosts. The one this replaced named five folders while the installer
128
+ * offered forty hosts, so an engine installed for Kiro, Cline, or Trae was reported missing and never
129
+ * unlocked. Every `.<host>/skills` at the root counts, plus every directory a supported host is
130
+ * documented to read (`skills/` for OpenClaw, nested ones) — a repo installs the engines wherever
131
+ * `npx skills add` was pointed, and that installer symlinks across several of them.
132
+ */
133
+ function skillRoots(target) {
134
+ const roots = new Set(PLATFORMS.flatMap((p) => p.reads));
135
+ if (fs.existsSync(target)) {
136
+ for (const e of fs.readdirSync(target, { withFileTypes: true })) {
137
+ if (!e.name.startsWith(".") || e.name === ".git") continue;
138
+ if (!e.isDirectory() && !e.isSymbolicLink()) continue;
139
+ if (fs.existsSync(path.join(target, e.name, "skills"))) roots.add(`${e.name}/skills`);
140
+ }
141
+ }
142
+ return [...roots];
143
+ }
107
144
 
108
145
  // BMad skills RETIRED at G5. This array is the single home of that list: `bmad-skill-register.md`
109
146
  // carries the same names for a reader, and a test fails when the two disagree.
@@ -169,7 +206,9 @@ function usage() {
169
206
  install [dir] first install (TUI unless --yes)
170
207
  update [dir] update (TUI unless --yes)
171
208
  verify [dir]
172
- engines [dir] [--fix] report the six engines, their invocation state, and the BMad G5 ban
209
+ engines [dir] [--fix] [--copy]
210
+ report the six engines, their invocation state, and the BMad G5 ban;
211
+ --copy puts the repo's own engine copies where each selected host reads
173
212
  upgrade-check [dir] re-probe what wdi-upgrade still owes; rewrites upgrade_pending (exit 1 if any)
174
213
  promote <live-dir> --rescue pull a method change back out of a consumer (not the normal flow)
175
214
 
@@ -182,6 +221,7 @@ function usage() {
182
221
  --doc-filename-language <text> slug part of document filenames; free text, default English
183
222
  --skip-bmad-check
184
223
  --skip-engines-check install without to-spec / to-tickets / implement
224
+ --copy-engines engines are in the repo but a selected host cannot read them: copy them there
185
225
 
186
226
  BMad first, then this package. ${WDI_REPO}
187
227
  `);
@@ -227,15 +267,21 @@ function parseArgs(argv) {
227
267
  const t = rest.shift();
228
268
  if (t === "--skip-bmad-check") args.skipBmad = true;
229
269
  else if (t === "--fix") args.fix = true;
270
+ else if (t === "--copy" || t === "--copy-engines") args.copyEngines = true;
230
271
  else if (t === "--skip-engines-check") args.skipEngines = true;
231
272
  else if (t === "--rescue") args.rescue = true;
232
273
  else if (t === "--yes" || t === "-y") args.yes = true;
233
274
  else if (t === "--agents") {
234
275
  const raw = rest.shift();
235
276
  if (!raw) die("--agents needs a comma-separated list");
236
- args.agents = normalizePlatformIds(raw.split(",").map((s) => s.trim()).filter(Boolean));
237
- const unknown = raw.split(",").map((s) => s.trim()).filter(Boolean)
238
- .filter((a) => !isKnownPlatform(a));
277
+ const asked = raw.split(",").map((s) => s.trim()).filter(Boolean);
278
+ const retired = asked.filter((a) => isRetiredPlatform(a));
279
+ if (retired.length) {
280
+ die(retired.map((a) => `${a} is no longer supported — ${RETIRED_PLATFORMS[normalizePlatformId(a)]}`)
281
+ .join("\n "));
282
+ }
283
+ args.agents = normalizePlatformIds(asked);
284
+ const unknown = asked.filter((a) => !isKnownPlatform(a));
239
285
  if (unknown.length) die(`unknown platform: ${unknown.join(", ")} (run --list-agents)`);
240
286
  if (!args.agents.length) die("--agents needs at least one known platform");
241
287
  } else if (t === "--product") args.product = rest.shift();
@@ -362,30 +408,73 @@ function requireTarget(dir) {
362
408
  */
363
409
  function repoSkillFiles(target, names) {
364
410
  const out = new Map();
365
- for (const home of SKILL_HOMES) {
411
+ for (const root of skillRoots(target)) {
366
412
  for (const name of names) {
367
- const file = path.join(target, home, "skills", name, "SKILL.md");
413
+ const file = path.join(target, root, name, "SKILL.md");
368
414
  if (!fs.existsSync(file)) continue;
369
415
  let real = file;
370
416
  try {
371
417
  real = fs.realpathSync(file);
372
418
  } catch {}
373
419
  if (!out.has(name)) out.set(name, new Map());
374
- out.get(name).set(real, file);
420
+ const seen = out.get(name);
421
+ if (!seen.has(real)) seen.set(real, []);
422
+ seen.get(real).push(file);
375
423
  }
376
424
  }
377
425
  return out;
378
426
  }
379
427
 
380
- /** The six engines, in the REPO. A user-level plugin is not an answer here — see ENGINE_SKILLS. */
381
- function enginesReport(target) {
428
+ /** The six engines, in the REPO — and visible to EVERY selected host.
429
+ *
430
+ * Present somewhere is not enough. Kiro reads `.kiro/skills` and nothing else, so engines in
431
+ * `.agents/skills` are invisible to it and `wdi-build` fails there at G5. `missingByHost` names, per
432
+ * selected host, the engines none of its `reads` directories holds. With no hosts given it falls
433
+ * back to "anywhere in the repo", which is what the unlock and the lock work on.
434
+ * A user-level plugin is not an answer here — see ENGINE_SKILLS.
435
+ */
436
+ function enginesReport(target, agents = []) {
382
437
  const files = repoSkillFiles(target, ENGINE_SKILLS);
383
438
  const missing = ENGINE_SKILLS.filter((n) => !files.has(n));
384
- return { files, missing, present: missing.length === 0 };
439
+ const missingByHost = new Map();
440
+ for (const id of normalizePlatformIds(agents)) {
441
+ const reads = getPlatform(id).reads.map((d) => path.join(target, d) + path.sep);
442
+ const visible = (name) => [...(files.get(name)?.values() || [])].flat()
443
+ .some((f) => reads.some((r) => f.startsWith(r)));
444
+ const lacking = ENGINE_SKILLS.filter((n) => !visible(n));
445
+ if (lacking.length) missingByHost.set(id, lacking);
446
+ }
447
+ const present = agents.length ? missingByHost.size === 0 : missing.length === 0;
448
+ return { files, missing, missingByHost, present };
385
449
  }
386
450
 
387
- function enginesPresent(target) {
388
- return enginesReport(target).present;
451
+ function enginesPresent(target, agents = []) {
452
+ return enginesReport(target, agents).present;
453
+ }
454
+
455
+ /** Engines that ARE in the repo, copied into the skill folder of each selected host that cannot see
456
+ * them. The source is the repo's own copy — no network, no third-party installer — which is what
457
+ * `npx skills add` in "copy" mode would have produced. An existing folder is never overwritten.
458
+ * Returns the number of engine folders written; engines missing from the repo entirely are left to
459
+ * `npx skills add`, because there is nothing here to copy from.
460
+ */
461
+ function copyEnginesToHosts(target, agents) {
462
+ const report = enginesReport(target, agents);
463
+ let written = 0;
464
+ for (const [id, names] of report.missingByHost) {
465
+ const host = getPlatform(id);
466
+ for (const name of names) {
467
+ const copies = report.files.get(name);
468
+ if (!copies) continue;
469
+ const source = path.dirname([...copies.keys()].sort()[0]);
470
+ const dest = path.join(target, ...host.skillDir.split("/"), name);
471
+ if (fs.existsSync(dest)) continue;
472
+ copyTree(source, dest);
473
+ written += 1;
474
+ }
475
+ note(`copied engines for ${host.name} → ${host.skillDir}/ (from the repo's own copy)`);
476
+ }
477
+ return written;
389
478
  }
390
479
 
391
480
  /** Only ever a WARNING. The plugin's copies are namespaced and still flagged, so they can neither be
@@ -483,7 +572,10 @@ function retireBmadG5(target) {
483
572
  * Merged, never replaced: a product's own permissions are its own. Invalid JSON is reported rather
484
573
  * than repaired — rewriting a settings file nobody can parse is how a repo loses its allowlist.
485
574
  */
486
- function writeDenyRules(target) {
575
+ function writeDenyRules(target, agents) {
576
+ // Claude Code's own settings file, for Claude Code only. Writing it for a Kiro-only repo would
577
+ // create a `.claude/` folder nothing reads.
578
+ if (!platformsWithManualOnly(agents, "claude").length) return 0;
487
579
  const file = path.join(target, ".claude", "settings.json");
488
580
  let settings = {};
489
581
  if (fs.existsSync(file)) {
@@ -510,6 +602,46 @@ function writeDenyRules(target) {
510
602
  return added.length;
511
603
  }
512
604
 
605
+ /** OpenCode's equivalent: `permission.skill: ask` in `opencode.json`. The model may still reach for
606
+ * the skill, and OpenCode then asks the person before loading it — a gate the model cannot pass
607
+ * alone, with the human route left open. `deny` would hide it from the person too.
608
+ *
609
+ * Merged, never replaced, under the same rules as `.claude/settings.json`: a value the product
610
+ * already set for a name is kept, and a file nobody can parse (`opencode.json` may carry comments) is
611
+ * reported rather than rewritten.
612
+ */
613
+ function writeOpencodePermissions(target, agents) {
614
+ if (!platformsWithManualOnly(agents, "opencode").length) return 0;
615
+ const file = path.join(target, "opencode.json");
616
+ let cfg = {};
617
+ if (fs.existsSync(file)) {
618
+ try {
619
+ cfg = JSON.parse(fs.readFileSync(file, "utf8"));
620
+ } catch {
621
+ note("opencode.json is not plain JSON — skill permissions NOT written; add them by hand: "
622
+ + `permission.skill "${WDI_MANUAL_ONLY[0]}": "ask" (and the rest in bmad-skill-register.md)`);
623
+ return 0;
624
+ }
625
+ if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) return 0;
626
+ }
627
+ const perm = cfg.permission && typeof cfg.permission === "object" && !Array.isArray(cfg.permission)
628
+ ? cfg.permission : {};
629
+ if (typeof perm.skill === "string") {
630
+ note(`opencode.json sets permission.skill to "${perm.skill}" for every skill — left as the product wrote it`);
631
+ return 0;
632
+ }
633
+ const skill = perm.skill && typeof perm.skill === "object" ? perm.skill : {};
634
+ const added = [...WDI_MANUAL_ONLY, ...BMAD_RETIRED_G5].filter((n) => !(n in skill));
635
+ if (!added.length) return 0;
636
+ for (const n of added) skill[n] = "ask";
637
+ perm.skill = skill;
638
+ cfg.permission = perm;
639
+ if (!cfg.$schema) cfg = { $schema: "https://opencode.ai/config.json", ...cfg };
640
+ fs.writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
641
+ note(`opencode.json: ${added.length} manual-only skill${added.length === 1 ? "" : "s"} set to "ask"`);
642
+ return added.length;
643
+ }
644
+
513
645
  /** A trace of what the engines were when the method last looked — not a lockfile.
514
646
  *
515
647
  * `npx skills add` writes its own `skills-lock.json` with a folder hash per skill, and that hash
@@ -565,18 +697,37 @@ function bmadMissingMessage() {
565
697
  //
566
698
  // So it blocks, and `--skip-engines-check` is the escape, exactly as `--skip-bmad-check` is for BMad. The
567
699
  // escape matters: CI installs into a bare checkout, and a repo that will never reach G5 is a real case.
568
- function enginesMissingMessage(missing) {
569
- const names = (missing && missing.length ? missing : ENGINE_SKILLS).join(" · ");
700
+ function enginesMissingMessage(report) {
701
+ const missing = report?.missing?.length ? report.missing
702
+ : report?.missingByHost?.size ? [] : ENGINE_SKILLS;
703
+ const byHost = report?.missingByHost?.size ? [...report.missingByHost] : [];
704
+ const head = [];
705
+ if (missing.length) {
706
+ head.push(`The engines are not in this repo. Missing: ${missing.join(" · ")}`, "",
707
+ ` ${ENGINES_INSTALL_ANY}`);
708
+ }
709
+ if (byHost.length) {
710
+ if (head.length) head.push("");
711
+ head.push("Not where every selected host can read them — install for each host named here:", "");
712
+ for (const [id, names] of byHost) {
713
+ const p = getPlatform(id);
714
+ head.push(` ${p.name} reads ${p.reads.join(", ")} — cannot see ${names.join(" · ")}`,
715
+ ` ${ENGINES_INSTALL_ANY} --agent ${p.skillsCli} (lands them where ${p.name} reads)`);
716
+ }
717
+ head.push("",
718
+ "Or, without the network — the engines are already in this repo, just not where those hosts read:",
719
+ " npx wdi-method engines --copy (or add --copy-engines to install / update)",
720
+ "Or leave those hosts out if this repo does not use them: --agents <the hosts you use>");
721
+ }
570
722
  return [
571
- `The engines are not in this repo. Missing: ${names}`,
723
+ ...head,
572
724
  "",
573
725
  "They MUST be installed INTO the repo, not as a user-level plugin — the method strips",
574
726
  "`disable-model-invocation` from its own copies so `wdi-build` and `wdi-autopilot` can drive",
575
727
  "them, and a plugin's files are not the repo's to edit.",
576
728
  "",
577
- ` ${ENGINES_INSTALL_ANY}`,
578
- "",
579
- `Take all six: ${ENGINE_SKILLS.join(" · ")}. Choose "copy" or "symlink" — either is read.`,
729
+ `Take all six: ${ENGINE_SKILLS.join(" · ")}. Choose "copy" or "symlink" — either is read; on Windows`,
730
+ "without Developer Mode a symlink fails and the installer falls back to copy, which is fine.",
580
731
  "",
581
732
  "You do NOT need to run the setup skill after this — the installer seeds docs/agents/ already",
582
733
  `answered for this method. Run ${ENGINES_SETUP} only to change tracker.`,
@@ -1304,9 +1455,10 @@ function upgradeCheck(target) {
1304
1455
  return 1;
1305
1456
  }
1306
1457
 
1307
- function writeStamp(target) {
1458
+ function writeStamp(target, agents) {
1308
1459
  const control = path.join(target, ".control");
1309
1460
  if (!fs.existsSync(control)) return;
1461
+ const hosts = normalizePlatformIds(agents && agents.length ? agents : declaredPlatforms(target, fs));
1310
1462
  const eng = enginesReport(target);
1311
1463
  const fp = engineFingerprints(target);
1312
1464
  const names = Object.keys(fp);
@@ -1314,6 +1466,12 @@ function writeStamp(target) {
1314
1466
  "# Written by wdi-method install/update. A trace, not a lockfile.",
1315
1467
  `wdi_method: ${PKG.version}`,
1316
1468
  `bmad_method: ${readBmadVersion(target) || '""'}`,
1469
+ "# The hosts this repo was installed for. `update` reuses this list; the skills and validate.py",
1470
+ "# read `hosts:` to learn where each host reads skills, how a person types one, and whether the",
1471
+ "# host has its own scheduler.",
1472
+ "platforms:",
1473
+ ...hosts.map((id) => ` - ${id}`),
1474
+ hostsYaml(hosts),
1317
1475
  ];
1318
1476
  if (names.length) {
1319
1477
  const blocked = engineInvocationState(target).blocked;
@@ -1632,34 +1790,20 @@ function upsertAgentFiles(target, platforms, productName) {
1632
1790
  if (!next.endsWith("\n")) next += "\n";
1633
1791
  fs.writeFileSync(agentsFile, next);
1634
1792
 
1635
- const mirrors = [];
1636
- if (platformUsesHook(platforms, "cursorrules")) {
1637
- mirrors.push(path.join(target, ".cursorrules"));
1638
- }
1639
- if (platformUsesHook(platforms, "agents-mirror")) {
1640
- mirrors.push(path.join(target, ".agents", "AGENTS.md"));
1641
- }
1642
- for (const mirror of mirrors) {
1793
+ // Every rule file a selected host reads in place of, or beside, AGENTS.md — CLAUDE.md, GEMINI.md,
1794
+ // .junie/guidelines.md, … — carries the same block. A host that never sees the block never sees
1795
+ // the method policy, and on a host with no manual-only gate that policy is the lock.
1796
+ for (const rel of ruleFiles(platforms).slice(1)) {
1797
+ const mirror = path.join(target, ...rel.split("/"));
1643
1798
  fs.mkdirSync(path.dirname(mirror), { recursive: true });
1644
- if (fs.existsSync(mirror)) {
1645
- const patched = upsertMethodBlock(fs.readFileSync(mirror, "utf8"), template);
1646
- fs.writeFileSync(mirror, patched.endsWith("\n") ? patched : `${patched}\n`);
1647
- note(`method block refreshed in ${posixRel(target, mirror)}`);
1648
- } else {
1799
+ const current = fs.existsSync(mirror) ? fs.readFileSync(mirror, "utf8") : "";
1800
+ if (!current.trim() || current.trim() === "@AGENTS.md") {
1649
1801
  fs.writeFileSync(mirror, next);
1650
- note(`created ${posixRel(target, mirror)}`);
1651
- }
1652
- }
1653
-
1654
- if (platformUsesHook(platforms, "claude-md")) {
1655
- const claude = path.join(target, "CLAUDE.md");
1656
- if (!fs.existsSync(claude) || fs.readFileSync(claude, "utf8").trim() === "@AGENTS.md") {
1657
- fs.writeFileSync(claude, next);
1658
- note("CLAUDE.md synchronized from AGENTS.md");
1802
+ note(current.trim() ? `${rel} synchronized from AGENTS.md` : `created ${rel}`);
1659
1803
  } else {
1660
- const patched = upsertMethodBlock(fs.readFileSync(claude, "utf8"), template);
1661
- fs.writeFileSync(claude, patched.endsWith("\n") ? patched : `${patched}\n`);
1662
- note("method block refreshed in CLAUDE.md");
1804
+ const patched = upsertMethodBlock(current, template);
1805
+ fs.writeFileSync(mirror, patched.endsWith("\n") ? patched : `${patched}\n`);
1806
+ note(`method block refreshed in ${rel}`);
1663
1807
  }
1664
1808
  }
1665
1809
  }
@@ -1715,12 +1859,17 @@ function printSummary(target, agents, { first, was, written, skipped, skills, to
1715
1859
  `run the ${INIT_SKILL} skill, intent ${DIM}readers${RESET}, ` +
1716
1860
  `to write it for this repo's stack`);
1717
1861
  }
1718
- const engReport = enginesReport(target);
1862
+ const engReport = enginesReport(target, agents);
1719
1863
  summaryLine("engines", engReport.present
1720
- ? `${ENGINE_SKILLS.join(" · ")} — found (in this repo)`
1721
- : `NOT found: ${engReport.missing.join(" · ")}. G5 (wdi-build) and the Fast Path need them; G1–G4 run without them`);
1864
+ ? `${ENGINE_SKILLS.join(" · ")} — found (in this repo), readable by every selected host`
1865
+ : engReport.missing.length
1866
+ ? `NOT found: ${engReport.missing.join(" · ")}. G5 (wdi-build) and the Fast Path need them; G1–G4 run without them`
1867
+ : `NOT readable by ${[...engReport.missingByHost.keys()].join(", ")}. G5 (wdi-build) and the Fast Path need them; G1–G4 run without them`);
1722
1868
  if (!engReport.present) {
1723
- summaryLine("", `${DIM}·${RESET} into THIS repo: ${DIM}${ENGINES_INSTALL_ANY}${RESET} — a user-level plugin does not count`);
1869
+ for (const [id, names] of engReport.missingByHost) {
1870
+ summaryLine("", `${DIM}·${RESET} ${id}: ${names.join(" · ")} — ${DIM}${ENGINES_INSTALL_ANY} --agent ${getPlatform(id).skillsCli}${RESET}`);
1871
+ }
1872
+ summaryLine("", `${DIM}·${RESET} into THIS repo — a user-level plugin does not count`);
1724
1873
  summaryLine("", `${DIM}·${RESET} docs/agents/ is already seeded, so ${DIM}${ENGINES_SETUP}${RESET} is not needed · ${ENGINES_REPO}`);
1725
1874
  } else {
1726
1875
  const blocked = engineInvocationState(target).blocked;
@@ -1838,8 +1987,10 @@ function apply(target, agents,
1838
1987
  // wrappers. A one-time fix would hold for about a week.
1839
1988
  enableEngineInvocation(target);
1840
1989
  retireBmadG5(target);
1841
- writeDenyRules(target);
1842
- writeStamp(target);
1990
+ writeDenyRules(target, agents);
1991
+ writeOpencodePermissions(target, agents);
1992
+ noteRetiredPlatforms(target);
1993
+ writeStamp(target, agents);
1843
1994
  printSummary(target, agents, { first, was, written, skipped, skills, tomls, opencodeCmds });
1844
1995
  printNextSteps({
1845
1996
  first,
@@ -1848,16 +1999,34 @@ function apply(target, agents,
1848
1999
  });
1849
2000
  }
1850
2001
 
1851
- function enginesCommand(target, { fix }) {
1852
- const before = enginesReport(target);
2002
+ /** A host the repo still names that the method dropped. Said, never acted on: its folder holds
2003
+ * whatever the product put there too, and on update absence is a decision the owner makes. */
2004
+ function noteRetiredPlatforms(target) {
2005
+ for (const id of retiredPlatformsIn(target, fs)) {
2006
+ note(`${id} is no longer supported — ${RETIRED_PLATFORMS[id]}. Its wdi-* skill folders were left `
2007
+ + "in place; remove them by hand when you are ready");
2008
+ }
2009
+ }
2010
+
2011
+ function enginesCommand(target, { fix, copy }) {
2012
+ const agents = declaredPlatforms(target, fs);
2013
+ if (copy) {
2014
+ const n = copyEnginesToHosts(target, agents);
2015
+ if (n) enableEngineInvocation(target);
2016
+ ok(n ? `copied ${n} engine folder${n === 1 ? "" : "s"}` : "nothing to copy — every selected host already reads the engines, or none are in the repo");
2017
+ }
2018
+ const before = enginesReport(target, agents);
1853
2019
  console.log("");
1854
- console.log(` engines ${before.present ? "all present" : `MISSING ${before.missing.join(" · ")}`}`);
2020
+ console.log(` engines ${before.present ? "all present" : before.missing.length ? `MISSING ${before.missing.join(" · ")}` : "present, but not where every host reads"}`);
2021
+ for (const [id, names] of before.missingByHost) {
2022
+ console.log(` ${id.padEnd(17)}cannot read ${names.join(" · ")} ${DIM}${ENGINES_INSTALL_ANY} --agent ${getPlatform(id).skillsCli}${RESET}`);
2023
+ }
1855
2024
  for (const name of ENGINE_SKILLS) {
1856
2025
  const copies = before.files.get(name);
1857
2026
  if (!copies) continue;
1858
2027
  const flagged = [...copies.keys()].some((f) =>
1859
2028
  /^disable-model-invocation\s*:\s*true/m.test(fs.readFileSync(f, "utf8")));
1860
- const where = [...copies.values()].map((f) => posixRel(target, f)).join(", ");
2029
+ const where = [...copies.values()].flat().map((f) => posixRel(target, f)).join(", ");
1861
2030
  console.log(` ${name.padEnd(17)}${flagged ? "flagged — no skill can invoke it" : "invocable"} ${DIM}${where}${RESET}`);
1862
2031
  }
1863
2032
  const banned = repoSkillFiles(target, BMAD_RETIRED_G5);
@@ -1885,12 +2054,13 @@ function enginesCommand(target, { fix }) {
1885
2054
  repairAgentDocs(target);
1886
2055
  enableEngineInvocation(target);
1887
2056
  retireBmadG5(target);
1888
- writeDenyRules(target);
1889
- writeStamp(target);
2057
+ writeDenyRules(target, agents);
2058
+ writeOpencodePermissions(target, agents);
2059
+ writeStamp(target, agents);
1890
2060
  ok("engines aligned");
1891
2061
  if (!before.present) {
1892
2062
  console.log("");
1893
- console.log(enginesMissingMessage(before.missing));
2063
+ console.log(enginesMissingMessage(before));
1894
2064
  }
1895
2065
  }
1896
2066
 
@@ -2127,7 +2297,7 @@ async function runWizard(pre) {
2127
2297
  // both is told about BMad first rather than sent to install the second thing.
2128
2298
  const engineGate = enginesReport(target);
2129
2299
  if (!engineGate.present && !pre.skipEngines) {
2130
- p.note(enginesMissingMessage(engineGate.missing), "Engines next");
2300
+ p.note(enginesMissingMessage(engineGate), "Engines next");
2131
2301
  p.outro("Install them into this repo, then run this again: npx wdi-method");
2132
2302
  process.exit(1);
2133
2303
  }
@@ -2204,17 +2374,48 @@ async function runWizard(pre) {
2204
2374
  const detected = pre.agents
2205
2375
  ? normalizePlatformIds(pre.agents)
2206
2376
  : detectPlatforms(target, fs);
2207
- const selected = cancelIf(
2377
+ let selected = cancelIf(
2208
2378
  await p.autocompleteMultiselect({
2209
2379
  message: "Which tools get the wdi-* skills? (⭐ = recommended)",
2210
2380
  options: platformSelectOptions(detected),
2211
2381
  initialValues: detected,
2212
2382
  required: true,
2213
2383
  maxItems: 8,
2214
- placeholder: "Type to search…",
2384
+ placeholder: `Type to search all ${PLATFORMS.length} hosts — Space selects, Enter confirms`,
2215
2385
  }),
2216
2386
  );
2217
2387
 
2388
+ // Step 2 again, now that the hosts are known. Present somewhere is not enough: Kiro reads
2389
+ // `.kiro/skills` only, so engines in `.agents/skills` leave it unable to run G5.
2390
+ let hostGate = enginesReport(target, selected);
2391
+ if (!hostGate.present && !pre.skipEngines && !hostGate.missing.length) {
2392
+ // The engines are in the repo, only not where these hosts read. That is fixable right here.
2393
+ const blind = [...hostGate.missingByHost.keys()];
2394
+ const names = blind.map((id) => getPlatform(id).name).join(", ");
2395
+ const choice = cancelIf(await p.select({
2396
+ message: `The engines are in this repo, but ${names} cannot read them. What now?`,
2397
+ options: [
2398
+ { value: "copy", label: `Copy them where ${names} read`, hint: "from this repo's own copy, no network" },
2399
+ { value: "drop", label: `Leave ${names} out of this install`, hint: "if this repo does not use them" },
2400
+ { value: "stop", label: "Stop — I will run npx skills add myself" },
2401
+ ],
2402
+ initialValue: "copy",
2403
+ }));
2404
+ if (choice === "copy") copyEnginesToHosts(target, selected);
2405
+ if (choice === "drop") selected = selected.filter((id) => !blind.includes(id));
2406
+ if (choice === "stop" || !selected.length) {
2407
+ p.note(enginesMissingMessage(hostGate), "Engines for the hosts you picked");
2408
+ p.outro("Install them where each host reads, then run this again: npx wdi-method");
2409
+ process.exit(1);
2410
+ }
2411
+ hostGate = enginesReport(target, selected);
2412
+ }
2413
+ if (!hostGate.present && !pre.skipEngines) {
2414
+ p.note(enginesMissingMessage(hostGate), "Engines for the hosts you picked");
2415
+ p.outro("Install them where each host reads, then run this again: npx wdi-method");
2416
+ process.exit(1);
2417
+ }
2418
+
2218
2419
  p.note(
2219
2420
  [
2220
2421
  "The corpus folder names are fixed — they are not an install option:",
@@ -2222,9 +2423,9 @@ async function runWizard(pre) {
2222
2423
  "",
2223
2424
  "What gets written for the platforms you picked:",
2224
2425
  " AGENTS.md (the BEGIN:wdi-method block — always)",
2225
- platformUsesHook(selected, "claude-md") ? " CLAUDE.md (method block mirror)" : "",
2226
- platformUsesHook(selected, "cursorrules") ? " .cursorrules (method block mirror)" : "",
2227
- platformUsesHook(selected, "agents-mirror") ? " .agents/AGENTS.md (method block mirror)" : "",
2426
+ ...ruleFiles(selected).slice(1).map((f) => ` ${f} (method block mirror)`),
2427
+ platformsWithManualOnly(selected, "claude").length ? " .claude/settings.json (deny rules, merged)" : "",
2428
+ platformsWithManualOnly(selected, "opencode").length ? " opencode.json (permission.skill: ask, merged)" : "",
2228
2429
  platformUsesHook(selected, "opencode-commands")
2229
2430
  ? ` ${opencodeCommandsDir()}/wdi-*.md (slash commands → skills)`
2230
2431
  : "",
@@ -2237,12 +2438,12 @@ async function runWizard(pre) {
2237
2438
 
2238
2439
  const okGo = cancelIf(await p.confirm({ message: first ? "Run the install?" : "Run the update?", initialValue: true }));
2239
2440
  if (!okGo) {
2240
- p.cancel("Dibatalkan.");
2441
+ p.cancel("Cancelled.");
2241
2442
  process.exit(0);
2242
2443
  }
2243
2444
 
2244
2445
  const spinner = p.spinner();
2245
- spinner.start(first ? "Memasang…" : "Meng-update…");
2446
+ spinner.start(first ? "Installing…" : "Updating…");
2246
2447
  apply(target, selected, {
2247
2448
  docLanguage,
2248
2449
  docFilenameLanguage,
@@ -2251,7 +2452,7 @@ async function runWizard(pre) {
2251
2452
  product: String(product).trim(),
2252
2453
  client: String(client).trim(),
2253
2454
  });
2254
- spinner.stop(first ? "Terpasang" : "Ter-update");
2455
+ spinner.stop(first ? "Installed" : "Updated");
2255
2456
  p.outro(first ? "Done. Take the after-install steps above." : "Done. Read the method-block diff in AGENTS.md.");
2256
2457
  }
2257
2458
 
@@ -2265,8 +2466,9 @@ function runNonInteractive(args) {
2265
2466
  if (!args.skipBmad && !bmadPresent(target)) {
2266
2467
  die(bmadMissingMessage());
2267
2468
  }
2268
- if (!args.skipEngines && !enginesPresent(target)) {
2269
- die(enginesMissingMessage(enginesReport(target).missing));
2469
+ if (args.copyEngines) copyEnginesToHosts(target, agents);
2470
+ if (!args.skipEngines && !enginesPresent(target, agents)) {
2471
+ die(enginesMissingMessage(enginesReport(target, agents)));
2270
2472
  }
2271
2473
  const existing = readIndexIdentity(target);
2272
2474
  const product = args.product || existing.name;
@@ -2308,7 +2510,7 @@ async function main() {
2308
2510
  return;
2309
2511
  }
2310
2512
  if (args.cmd === "engines") {
2311
- enginesCommand(requireTarget(args.dir), { fix: Boolean(args.fix) });
2513
+ enginesCommand(requireTarget(args.dir), { fix: Boolean(args.fix), copy: Boolean(args.copyEngines) });
2312
2514
  return;
2313
2515
  }
2314
2516
  if (args.cmd === "upgrade-check") {