plain-forge 1.0.15 → 1.0.16

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 CHANGED
@@ -58,29 +58,47 @@ npx plain-forge install --agent claude --scope project
58
58
 
59
59
  **Agent options:**
60
60
 
61
- | `--agent` | Installs into | Use when |
61
+ | `--agent` | Installs into (project · global) | Use when |
62
62
  |-----------|---------------|----------|
63
- | `claude` | `.claude/` | You use Claude Code |
64
- | `codex` | `.codex/` | You use the OpenAI Codex CLI |
65
- | `forgecode` | `.forgecode/` | You use ForgeCode |
66
- | `universal` | `.agents/` | You want a runtime-neutral layout that any agent reading from `.agents/` can pick up |
63
+ | `claude` | `.claude/` · `~/.claude/` | You use Claude Code |
64
+ | `codex` | `.agents/` · `~/.agents/` | You use the OpenAI Codex CLI |
65
+ | `forgecode` | `.forge/` · `~/forge/` | You use ForgeCode |
66
+ | `opencode` | `.opencode/` · `~/.config/opencode/` | You use OpenCode |
67
+ | `universal` | `.agents/` · `~/.agents/` | You want a runtime-neutral layout that any agent reading from `.agents/` can pick up |
68
+
69
+ > **Why `codex` and `forgecode` don't install into `.codex/` or `.forgecode/`.** Those tools load *skills* from specific directories that are **not** named after the tool. Per the current (2026) docs, Codex reads skills from `.agents/skills` (`~/.agents/skills` globally) — `.codex/` is config-only — and ForgeCode reads them from `.forge/skills` (`~/forge/skills` globally). plain-forge installs where the skills will actually load. As a result `codex` and `universal` share the `.agents/` layout; the install manifest records which agent you chose so `update`/`uninstall` behave correctly.
67
70
 
68
71
  **Scope options:**
69
72
 
70
73
  | `--scope` | Installs into | Use when |
71
74
  |-----------|---------------|----------|
72
- | `project` | `./<agent-dir>/` in the current working directory | You want plain-forge in just this project |
73
- | `global` | `~/<agent-dir>/` in your home directory | You want plain-forge available in every project on the machine |
75
+ | `project` | the agent's project dir in the current working directory | You want plain-forge in just this project |
76
+ | `global` | the agent's user-level dir in your home directory | You want plain-forge available in every project on the machine |
77
+
78
+ (See the per-agent table above for the exact project/global directory each agent uses — a few diverge from the plain `~/<dir>` pattern, e.g. OpenCode global is `~/.config/opencode/` and ForgeCode global is `~/forge/`.)
74
79
 
75
80
  Each install writes three subfolders under the chosen directory:
76
81
 
77
82
  ```
78
83
  <agent-dir>/
79
84
  skills/ # every plain-forge skill
80
- rules/ # spec-writing rules (loaded as workspace instructions)
85
+ rules/ # spec-writing rules (see "How the rules get applied" below)
81
86
  docs/ # shared reference docs
82
87
  ```
83
88
 
89
+ #### How the rules get applied per agent
90
+
91
+ The `skills/` are discovered natively by every supported agent. The `rules/` (the `.plain`-authoring guidance) are applied differently because each agent loads instruction files its own way — so plain-forge does a bit of extra, agent-specific wiring:
92
+
93
+ | Agent | How rules are applied |
94
+ |-------|-----------------------|
95
+ | **Claude Code** | Native — Claude Code auto-loads `.claude/rules/*.md` at launch. Each rule carries `paths: "**/*.plain"` frontmatter so Claude scopes it to `.plain` files. No extra wiring. |
96
+ | **OpenCode** | OpenCode doesn't auto-load a `rules/` dir; it reads globs from the `instructions` array in `opencode.json`. plain-forge **merges** a rules glob there — `.opencode/rules/*.md` (project, written to `./opencode.json`) or the absolute `~/.config/opencode/rules/*.md` (global, written to `~/.config/opencode/opencode.json`; absolute because OpenCode doesn't expand `~`). |
97
+ | **Codex / ForgeCode** | Neither auto-loads a `rules/` dir; both read custom instructions only from `AGENTS.md`. plain-forge appends a fenced, **managed pointer block** to `AGENTS.md` telling the agent to read the installed rule files. Project → repo-root `./AGENTS.md`; global → `~/.codex/AGENTS.md` (Codex) or `~/forge/AGENTS.md` (ForgeCode). |
98
+ | **Universal** | Verbatim copy only — the rules land in `.agents/rules/`, and it's up to whichever agent reads `.agents/` to consume them. No config is generated. |
99
+
100
+ In every case where plain-forge touches a config or `AGENTS.md` file, it **merges** into what's already there — adding only its own glob/block and leaving all your other content intact — and it never writes into a malformed config (it warns instead). `uninstall` reverses the wiring: it removes only plain-forge's glob/block, deleting the file only if it contained nothing else.
101
+
84
102
  `install` refuses to run if plain-forge is already present at the target directory — it prints a message pointing you at `update` and exits non-zero. Use `update` (below) to refresh an existing install.
85
103
 
86
104
  #### Updating an existing install
@@ -101,7 +119,7 @@ Installs that predate the manifest (anyone who installed before this feature exi
101
119
  npx plain-forge uninstall
102
120
  ```
103
121
 
104
- `uninstall` reads the install manifest (`<agent-dir>/.plain-forge/manifest.json`) and deletes **exactly** the files plain-forge wrote, then the manifest itself, then any directory left empty (including the agent directory). Your own skills and third-party content are never in the manifest, so they are never touched.
122
+ `uninstall` reads the install manifest (`<agent-dir>/.plain-forge/manifest.json`) and deletes **exactly** the files plain-forge wrote, then the manifest itself, then removes its rules wiring (the `opencode.json` glob or the `AGENTS.md` pointer block, for the relevant agents), then any directory left empty (including the agent directory). Your own skills and third-party content are never in the manifest, so they are never touched.
105
123
 
106
124
  By default it removes **every** agent layout in the **project** scope (the current folder). Narrow it with flags:
107
125
 
@@ -111,7 +129,7 @@ npx plain-forge uninstall --agent claude --scope global
111
129
 
112
130
  | Flag | Default | Values |
113
131
  |------|---------|--------|
114
- | `--agent` | `*` (all agents) | `claude`, `codex`, `forgecode`, `universal`, or `*` |
132
+ | `--agent` | `*` (all agents) | `claude`, `codex`, `forgecode`, `opencode`, `universal`, or `*` |
115
133
  | `--scope` | `project` | `project` (cwd) or `global` (home directory) |
116
134
 
117
135
  If an install has **no manifest** (e.g. one that predates manifests), `uninstall` cannot tell which files are plain-forge's, so it refuses to delete anything: it prints an error, lists the directories to clean up by hand, and exits non-zero. Refresh such an install with `update` first (which writes a manifest going forward), then `uninstall`.
@@ -191,7 +209,7 @@ test/
191
209
  package.json # ships only `bin/cli.mjs` and `forge/` to npm
192
210
  ```
193
211
 
194
- On `install`, the CLI reads `forge/skills` and `forge/rules` and writes them into the chosen agent directory (`.claude/`, `.codex/`, `.forgecode/`, or `.agents/`), recording every file it wrote in `<agent-dir>/.plain-forge/manifest.json` so `update` can later refresh and prune precisely.
212
+ On `install`, the CLI reads `forge/skills` and `forge/rules` and writes them into the chosen agent directory (`.claude/`, `.agents/` for Codex/universal, `.forge/`, or `.opencode/` — see the per-agent table above for global-scope paths), recording every file it wrote in `<agent-dir>/.plain-forge/manifest.json` so `update` can later refresh and prune precisely. For agents that need it, it also wires the rules into `opencode.json` or `AGENTS.md` (see [How the rules get applied per agent](#how-the-rules-get-applied-per-agent)).
195
213
 
196
214
  ## Available Skills
197
215
 
package/bin/cli.mjs CHANGED
@@ -9,10 +9,22 @@ const __filename = fileURLToPath(import.meta.url);
9
9
  const pkgRoot = path.resolve(path.dirname(__filename), "..");
10
10
  const forgeDir = path.join(pkgRoot, "forge");
11
11
 
12
+ // Agent name → the project-scope content directory plain-forge writes into.
13
+ // These are the directories each tool actually loads skills from (verified
14
+ // against the 2026 docs), NOT necessarily a dir named after the tool:
15
+ // - Codex reads skills from .agents/skills (never .codex/skills); .codex is
16
+ // config-only. So codex shares the .agents layout with `universal`.
17
+ // - ForgeCode reads skills from .forge/skills (never .forgecode/skills); its
18
+ // global dir is ~/forge (no dot) — see resolveBaseDir.
19
+ // - OpenCode reads project skills from .opencode; global is ~/.config/opencode
20
+ // — see resolveBaseDir.
21
+ // Some scopes/agents diverge from `path.join(root, dir)`; resolveBaseDir owns
22
+ // those exceptions.
12
23
  const AGENTS = {
13
24
  claude: ".claude",
14
- codex: ".codex",
15
- forgecode: ".forgecode",
25
+ codex: ".agents",
26
+ forgecode: ".forge",
27
+ opencode: ".opencode",
16
28
  universal: ".agents",
17
29
  };
18
30
  const SCOPES = ["project", "global"];
@@ -83,7 +95,8 @@ Commands:
83
95
  uninstall Remove a plain-forge install using its manifest
84
96
 
85
97
  Install options:
86
- --agent <claude|codex|forgecode|universal> Target agent layout
98
+ --agent <claude|codex|forgecode|opencode|universal>
99
+ Target agent layout
87
100
  --scope <project|global> Install into cwd or $HOME
88
101
  -h, --help Show this help
89
102
 
@@ -92,7 +105,8 @@ Update options:
92
105
  confirming each one
93
106
 
94
107
  Uninstall options:
95
- --agent <claude|codex|forgecode|universal|*> Which install to remove
108
+ --agent <claude|codex|forgecode|opencode|universal|*>
109
+ Which install to remove
96
110
  (default: * — every agent)
97
111
  --scope <project|global> Where to look (default: project)
98
112
 
@@ -292,6 +306,337 @@ function writeContent(baseDir) {
292
306
  return { counts, files };
293
307
  }
294
308
 
309
+ // Absolute install directory for an agent in a given scope. Global scope roots
310
+ // at $HOME, project scope at the cwd. OpenCode is the one exception: its global
311
+ // config lives under XDG at ~/.config/opencode — it does NOT read ~/.opencode —
312
+ // while its project layout is ./.opencode like every other agent.
313
+ function resolveBaseDir(agent, scope) {
314
+ const root = scope === "global" ? os.homedir() : process.cwd();
315
+ if (agent === "opencode" && scope === "global") {
316
+ return path.join(root, ".config", "opencode");
317
+ }
318
+ if (agent === "forgecode" && scope === "global") {
319
+ // ForgeCode's global content dir is ~/forge, not ~/.forge.
320
+ return path.join(root, "forge");
321
+ }
322
+ return path.join(root, AGENTS[agent]);
323
+ }
324
+
325
+ // --- OpenCode instructions wiring -------------------------------------------
326
+ // OpenCode does not auto-read a rules/ directory the way it does skills/; it
327
+ // loads extra instruction files from the `instructions` glob array in
328
+ // opencode.json. So for opencode we merge a glob pointing at our rules dir into
329
+ // that file (creating it if needed, never disturbing the user's own keys) so
330
+ // the ***plain rules actually apply. This is opencode-specific — every other
331
+ // agent just gets the verbatim file copy.
332
+
333
+ const OPENCODE_SCHEMA = "https://opencode.ai/config.json";
334
+
335
+ // Location of opencode.json for a scope. Project config lives at the project
336
+ // root (cwd), global config under ~/.config/opencode.
337
+ function opencodeConfigPath(scope) {
338
+ if (scope === "global") {
339
+ return path.join(os.homedir(), ".config", "opencode", "opencode.json");
340
+ }
341
+ return path.join(process.cwd(), "opencode.json");
342
+ }
343
+
344
+ // The instructions glob to add. Project scope uses a cwd-relative glob (opencode
345
+ // resolves relative instruction paths against the launch cwd). Global scope must
346
+ // be absolute: opencode does not expand ~ and would resolve a relative path
347
+ // against the project cwd, not the config dir. Only top-level rules/*.md are
348
+ // wired in — the examples/ subtree holds reference scripts, not instructions.
349
+ function opencodeRulesGlob(scope) {
350
+ if (scope === "global") {
351
+ return toPosix(
352
+ path.join(os.homedir(), ".config", "opencode", "rules", "*.md"),
353
+ );
354
+ }
355
+ return ".opencode/rules/*.md";
356
+ }
357
+
358
+ // Add our rules glob to opencode.json's `instructions`, creating or merging the
359
+ // file. Returns a status: "created" | "merged" | "present" | "skipped".
360
+ function mergeOpencodeInstructions(scope) {
361
+ const configPath = opencodeConfigPath(scope);
362
+ const glob = opencodeRulesGlob(scope);
363
+ const existed = fs.existsSync(configPath);
364
+
365
+ let config = {};
366
+ if (existed) {
367
+ try {
368
+ config = JSON.parse(fs.readFileSync(configPath, "utf8"));
369
+ } catch {
370
+ return { status: "skipped", reason: "malformed", configPath, glob };
371
+ }
372
+ if (config === null || typeof config !== "object" || Array.isArray(config)) {
373
+ return { status: "skipped", reason: "not-an-object", configPath, glob };
374
+ }
375
+ }
376
+
377
+ const list = Array.isArray(config.instructions) ? config.instructions : [];
378
+ if (list.includes(glob)) return { status: "present", configPath, glob };
379
+
380
+ // Only stamp $schema onto a file we are creating; never touch an existing one.
381
+ if (!existed && config.$schema === undefined) config.$schema = OPENCODE_SCHEMA;
382
+ config.instructions = list.concat(glob);
383
+
384
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
385
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
386
+ return { status: existed ? "merged" : "created", configPath, glob };
387
+ }
388
+
389
+ // Reverse of the merge: drop our glob from opencode.json's `instructions`. If
390
+ // that leaves a file containing only our scaffold ($schema + now-empty
391
+ // instructions), delete it; otherwise write back the trimmed config, leaving
392
+ // every user-authored key intact. Returns "removed" | "updated" | "absent" |
393
+ // "skipped".
394
+ function unmergeOpencodeInstructions(scope) {
395
+ const configPath = opencodeConfigPath(scope);
396
+ const glob = opencodeRulesGlob(scope);
397
+ if (!fs.existsSync(configPath)) return { status: "absent", configPath, glob };
398
+
399
+ let config;
400
+ try {
401
+ config = JSON.parse(fs.readFileSync(configPath, "utf8"));
402
+ } catch {
403
+ return { status: "skipped", reason: "malformed", configPath, glob };
404
+ }
405
+ if (config === null || typeof config !== "object" || Array.isArray(config)) {
406
+ return { status: "skipped", reason: "not-an-object", configPath, glob };
407
+ }
408
+ if (!Array.isArray(config.instructions) || !config.instructions.includes(glob)) {
409
+ return { status: "absent", configPath, glob };
410
+ }
411
+
412
+ config.instructions = config.instructions.filter((g) => g !== glob);
413
+
414
+ const onlyScaffold =
415
+ config.instructions.length === 0 &&
416
+ Object.keys(config).every((k) => k === "$schema" || k === "instructions");
417
+ if (onlyScaffold) {
418
+ fs.rmSync(configPath, { force: true });
419
+ return { status: "removed", configPath, glob };
420
+ }
421
+
422
+ if (config.instructions.length === 0) delete config.instructions;
423
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
424
+ return { status: "updated", configPath, glob };
425
+ }
426
+
427
+ // Human-readable line for a merge result, printed under the install/update
428
+ // summary. A skip is a warning the user should see (their config is untouched).
429
+ function reportOpencodeMerge(result) {
430
+ switch (result.status) {
431
+ case "created":
432
+ console.log(` opencode.json: created, wired rules via "${result.glob}"`);
433
+ break;
434
+ case "merged":
435
+ console.log(` opencode.json: added rules glob "${result.glob}"`);
436
+ break;
437
+ case "present":
438
+ console.log(` opencode.json: rules glob already present`);
439
+ break;
440
+ case "skipped":
441
+ console.log(
442
+ ` opencode.json: left untouched (${result.reason}) — add "${result.glob}" to its "instructions" manually`,
443
+ );
444
+ break;
445
+ }
446
+ }
447
+
448
+ // Human-readable line for an unmerge result during uninstall.
449
+ function reportOpencodeUnmerge(result) {
450
+ switch (result.status) {
451
+ case "removed":
452
+ console.log(` opencode.json: removed (was only plain-forge's rules wiring)`);
453
+ break;
454
+ case "updated":
455
+ console.log(` opencode.json: removed rules glob, kept your other config`);
456
+ break;
457
+ case "skipped":
458
+ console.log(
459
+ ` opencode.json: left untouched (${result.reason}) — remove "${result.glob}" from "instructions" manually`,
460
+ );
461
+ break;
462
+ }
463
+ }
464
+
465
+ // --- Codex / ForgeCode instructions wiring ----------------------------------
466
+ // Neither tool auto-reads a rules/ directory; both load custom instructions
467
+ // only from AGENTS.md (Codex: repo AGENTS.md or ~/.codex/AGENTS.md; ForgeCode:
468
+ // repo AGENTS.md or ~/forge/AGENTS.md). AGENTS.md has no include/glob mechanism,
469
+ // so we append a managed pointer block that tells the agent to read our rules
470
+ // dir when touching .plain files. The block is fenced by markers so it can be
471
+ // refreshed or removed idempotently without disturbing the user's own content.
472
+
473
+ const AGENTS_MD_BEGIN = "<!-- BEGIN plain-forge (managed) -->";
474
+ const AGENTS_MD_END = "<!-- END plain-forge (managed) -->";
475
+
476
+ // Agents whose rules are delivered through AGENTS.md rather than a natively
477
+ // auto-loaded rules/ dir (claude) or a config glob (opencode).
478
+ function usesAgentsMd(agent) {
479
+ return agent === "codex" || agent === "forgecode";
480
+ }
481
+
482
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
483
+
484
+ // Where the tool reads AGENTS.md from for a scope. Project scope is the repo
485
+ // root (cwd) for both tools; global scope differs per tool.
486
+ function agentsMdPath(agent, scope) {
487
+ if (scope === "project") return path.join(process.cwd(), "AGENTS.md");
488
+ if (agent === "forgecode") return path.join(os.homedir(), "forge", "AGENTS.md");
489
+ return path.join(os.homedir(), ".codex", "AGENTS.md"); // codex global
490
+ }
491
+
492
+ // Glob the pointer block references — the installed rules dir. Project scope is
493
+ // written relative to the repo root; global scope must be absolute (these files
494
+ // are consulted from arbitrary working directories).
495
+ function agentsMdRulesGlob(agent, scope) {
496
+ const rulesDir = path.join(resolveBaseDir(agent, scope), "rules");
497
+ if (scope === "global") return toPosix(path.join(rulesDir, "*.md"));
498
+ return toPosix(path.join(path.relative(process.cwd(), rulesDir), "*.md"));
499
+ }
500
+
501
+ function agentsMdBlock(glob) {
502
+ return [
503
+ AGENTS_MD_BEGIN,
504
+ "## ***plain authoring rules (plain-forge)",
505
+ "",
506
+ "When creating or editing `.plain` specification files, first read and follow",
507
+ `every rule file matching \`${glob}\`. Each file covers one section or topic of`,
508
+ "the ***plain language; they are installed and maintained by plain-forge.",
509
+ AGENTS_MD_END,
510
+ ].join("\n");
511
+ }
512
+
513
+ // Add or refresh the managed pointer block in AGENTS.md, creating the file if
514
+ // needed and preserving all user content. Returns a status:
515
+ // "created" | "merged" | "refreshed" | "present".
516
+ function mergeAgentsMd(agent, scope) {
517
+ const filePath = agentsMdPath(agent, scope);
518
+ const glob = agentsMdRulesGlob(agent, scope);
519
+ const block = agentsMdBlock(glob);
520
+ const existed = fs.existsSync(filePath);
521
+ let content = existed ? fs.readFileSync(filePath, "utf8") : "";
522
+
523
+ const hadBlock = content.includes(AGENTS_MD_BEGIN);
524
+ if (hadBlock) {
525
+ const re = new RegExp(
526
+ `${escapeRe(AGENTS_MD_BEGIN)}[\\s\\S]*?${escapeRe(AGENTS_MD_END)}`,
527
+ );
528
+ const replaced = content.replace(re, block);
529
+ if (replaced === content) return { status: "present", filePath, glob };
530
+ content = replaced;
531
+ } else {
532
+ if (content && !content.endsWith("\n")) content += "\n";
533
+ if (content) content += "\n";
534
+ content += block + "\n";
535
+ }
536
+
537
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
538
+ fs.writeFileSync(filePath, content);
539
+ const status = !existed ? "created" : hadBlock ? "refreshed" : "merged";
540
+ return { status, filePath, glob };
541
+ }
542
+
543
+ // Remove the managed pointer block. Deletes the file if nothing but the block
544
+ // (and whitespace) remains; otherwise writes back the user's content.
545
+ // Returns "removed" | "updated" | "absent".
546
+ function unmergeAgentsMd(agent, scope) {
547
+ const filePath = agentsMdPath(agent, scope);
548
+ if (!fs.existsSync(filePath)) return { status: "absent", filePath };
549
+ let content = fs.readFileSync(filePath, "utf8");
550
+ if (!content.includes(AGENTS_MD_BEGIN)) return { status: "absent", filePath };
551
+
552
+ const re = new RegExp(
553
+ `\\n*${escapeRe(AGENTS_MD_BEGIN)}[\\s\\S]*?${escapeRe(AGENTS_MD_END)}\\n*`,
554
+ );
555
+ content = content.replace(re, "\n");
556
+
557
+ if (content.trim() === "") {
558
+ fs.rmSync(filePath, { force: true });
559
+ return { status: "removed", filePath };
560
+ }
561
+ content = content.replace(/\n{3,}/g, "\n\n").replace(/^\n+/, "");
562
+ if (!content.endsWith("\n")) content += "\n";
563
+ fs.writeFileSync(filePath, content);
564
+ return { status: "updated", filePath };
565
+ }
566
+
567
+ function reportAgentsMdMerge(result) {
568
+ const rel = result.filePath;
569
+ switch (result.status) {
570
+ case "created":
571
+ console.log(` AGENTS.md: created at ${rel}, wired rules via "${result.glob}"`);
572
+ break;
573
+ case "merged":
574
+ console.log(` AGENTS.md: added rules block to ${rel}`);
575
+ break;
576
+ case "refreshed":
577
+ console.log(` AGENTS.md: refreshed rules block in ${rel}`);
578
+ break;
579
+ case "present":
580
+ console.log(` AGENTS.md: rules block already up to date`);
581
+ break;
582
+ }
583
+ }
584
+
585
+ function reportAgentsMdUnmerge(result) {
586
+ switch (result.status) {
587
+ case "removed":
588
+ console.log(` AGENTS.md: removed (was only plain-forge's rules block)`);
589
+ break;
590
+ case "updated":
591
+ console.log(` AGENTS.md: removed rules block, kept your other content`);
592
+ break;
593
+ }
594
+ }
595
+
596
+ // Wire rules for an agent whose mechanism needs it, during install/update.
597
+ // The skills/rules/docs and manifest are already on disk by the time this runs,
598
+ // so a failure here (e.g. an unwritable opencode.json/AGENTS.md) must not fail
599
+ // the whole install — it degrades to a warning and the install still succeeds.
600
+ function wireRules(agent, scope) {
601
+ try {
602
+ if (agent === "opencode") {
603
+ reportOpencodeMerge(mergeOpencodeInstructions(scope));
604
+ } else if (usesAgentsMd(agent)) {
605
+ reportAgentsMdMerge(mergeAgentsMd(agent, scope));
606
+ }
607
+ } catch (err) {
608
+ warnRulesWiring("wire", agent, err);
609
+ }
610
+ }
611
+
612
+ // Reverse of wireRules, during uninstall. Also non-fatal: the manifest files
613
+ // are already gone, so a failure to tidy up the rules wiring is a warning, not
614
+ // an uninstall failure.
615
+ function unwireRules(agent, scope) {
616
+ try {
617
+ if (agent === "opencode") {
618
+ reportOpencodeUnmerge(unmergeOpencodeInstructions(scope));
619
+ } else if (usesAgentsMd(agent)) {
620
+ reportAgentsMdUnmerge(unmergeAgentsMd(agent, scope));
621
+ }
622
+ } catch (err) {
623
+ warnRulesWiring("unwire", agent, err);
624
+ }
625
+ }
626
+
627
+ function warnRulesWiring(action, agent, err) {
628
+ const target = agent === "opencode" ? "opencode.json" : "AGENTS.md";
629
+ const verb = action === "wire" ? "wire up" : "clean up";
630
+ const msg = err instanceof Error ? err.message : String(err);
631
+ console.warn(
632
+ ` warning: could not ${verb} the ${agent} rules in ${target} (${msg}).`,
633
+ );
634
+ console.warn(
635
+ ` the skills and rule files were installed successfully; see the README`,
636
+ );
637
+ console.warn(` section "How the rules get applied per agent" to finish by hand.`);
638
+ }
639
+
295
640
  function manifestPathFor(baseDir) {
296
641
  return path.join(baseDir, MANIFEST_REL);
297
642
  }
@@ -306,12 +651,16 @@ function readManifest(baseDir) {
306
651
  return null;
307
652
  }
308
653
 
309
- function writeManifest(baseDir, files) {
654
+ function writeManifest(baseDir, files, agent) {
310
655
  const target = manifestPathFor(baseDir);
311
656
  fs.mkdirSync(path.dirname(target), { recursive: true });
657
+ // `agent` records which agent layout produced this install. It matters when
658
+ // two agents resolve to the same dir (codex and universal both use .agents):
659
+ // it lets detect/update/uninstall know whether AGENTS.md rule-wiring applies.
312
660
  const manifest = {
313
661
  name: "plain-forge",
314
662
  version: readPkgVersion(),
663
+ ...(agent ? { agent } : {}),
315
664
  files: files.map(toPosix).sort(),
316
665
  };
317
666
  fs.writeFileSync(target, JSON.stringify(manifest, null, 2) + "\n");
@@ -361,15 +710,22 @@ function deleteForgeFile(baseDir, rel) {
361
710
  // the flagship skill.
362
711
  function detectInstalls() {
363
712
  const installs = [];
713
+ const seen = new Set();
364
714
  for (const scope of SCOPES) {
365
- const root = scope === "global" ? os.homedir() : process.cwd();
366
- for (const [agent, dirName] of Object.entries(AGENTS)) {
367
- const baseDir = path.join(root, dirName);
715
+ for (const agent of Object.keys(AGENTS)) {
716
+ const baseDir = resolveBaseDir(agent, scope);
717
+ // Some agents share a directory (codex and universal both use .agents).
718
+ // Only inspect each physical dir once so a single install isn't detected
719
+ // — and later updated/pruned — twice.
720
+ if (seen.has(baseDir)) continue;
721
+ seen.add(baseDir);
368
722
  if (!fs.existsSync(baseDir)) continue;
369
723
  const manifest = readManifest(baseDir);
370
724
  const isLegacy = !manifest && hasForgeSignature(baseDir);
371
725
  if (manifest || isLegacy) {
372
- installs.push({ agent, scope, baseDir, manifest });
726
+ // Prefer the agent recorded at install time; fall back to the dir's
727
+ // first-mapped agent for legacy/manifest-less installs.
728
+ installs.push({ agent: manifest?.agent ?? agent, scope, baseDir, manifest });
373
729
  }
374
730
  }
375
731
  }
@@ -395,8 +751,7 @@ async function cmdInstall(args) {
395
751
  process.exit(2);
396
752
  }
397
753
 
398
- const root = scope === "global" ? os.homedir() : process.cwd();
399
- const baseDir = path.join(root, AGENTS[agent]);
754
+ const baseDir = resolveBaseDir(agent, scope);
400
755
 
401
756
  const alreadyInstalled =
402
757
  readManifest(baseDir) !== null || hasForgeSignature(baseDir);
@@ -407,12 +762,13 @@ async function cmdInstall(args) {
407
762
  }
408
763
 
409
764
  const { counts, files } = writeContent(baseDir);
410
- writeManifest(baseDir, files);
765
+ writeManifest(baseDir, files, agent);
411
766
 
412
767
  console.log(`installed into ${baseDir}`);
413
768
  console.log(` skills: ${counts.skills}`);
414
769
  console.log(` rules: ${counts.rules}`);
415
770
  console.log(` docs: ${counts.docs}`);
771
+ wireRules(agent, scope);
416
772
  console.log();
417
773
  printNextSteps(agent);
418
774
  }
@@ -453,6 +809,10 @@ async function cmdUpdate(args) {
453
809
  console.log(
454
810
  ` skills: ${counts.skills} rules: ${counts.rules} docs: ${counts.docs}`,
455
811
  );
812
+ // Re-assert rules wiring (opencode.json / AGENTS.md) — installs from before
813
+ // this feature lack it; the merge is idempotent for those that already have
814
+ // it, and refreshes the glob if the layout changed.
815
+ wireRules(inst.agent, inst.scope);
456
816
 
457
817
  // Pruning only applies to manifest-tracked installs. Each deprecated file
458
818
  // is confirmed individually before removal; denied files stay on disk and
@@ -484,7 +844,7 @@ async function cmdUpdate(args) {
484
844
 
485
845
  // Manifest reflects what's actually on disk: the fresh files plus any
486
846
  // deprecated files the user chose to keep.
487
- writeManifest(inst.baseDir, files.concat(kept));
847
+ writeManifest(inst.baseDir, files.concat(kept), inst.agent);
488
848
  console.log();
489
849
  updated++;
490
850
  }
@@ -528,7 +888,7 @@ async function cmdUninstall(args) {
528
888
  let hadError = false;
529
889
 
530
890
  for (const agent of agents) {
531
- const baseDir = path.join(root, AGENTS[agent]);
891
+ const baseDir = resolveBaseDir(agent, scope);
532
892
  if (!fs.existsSync(baseDir)) continue;
533
893
 
534
894
  const manifest = readManifest(baseDir);
@@ -560,19 +920,31 @@ async function cmdUninstall(args) {
560
920
  if (deleteForgeFile(baseDir, rel)) deleted++;
561
921
  else failed++;
562
922
  }
563
- // Finally remove the manifest itself, then prune the now-empty .plain-forge
564
- // directory and the agent directory if nothing else remains in it.
923
+ // Remove the manifest itself.
565
924
  fs.rmSync(manifestPathFor(baseDir), { force: true });
566
- removeEmptyDirsUpward(
567
- path.join(baseDir, path.dirname(MANIFEST_REL)),
568
- baseDir,
569
- );
570
- removeEmptyDirsUpward(baseDir, root);
571
925
 
572
926
  console.log(`uninstalled ${agent} (${scope}) from ${baseDir}`);
573
927
  console.log(
574
928
  ` removed ${deleted} file(s)${failed ? `, ${failed} could not be removed` : ""} + manifest`,
575
929
  );
930
+
931
+ // Undo the rules wiring (opencode.json / AGENTS.md). Keyed off the agent
932
+ // recorded at install time, not the requested name — codex and universal
933
+ // share the .agents dir, and only the codex install wired AGENTS.md. Done
934
+ // before the dir cleanup so a now-orphaned config file living inside baseDir
935
+ // (e.g. forgecode-global ~/forge/AGENTS.md) can be removed and let baseDir
936
+ // itself be pruned.
937
+ unwireRules(manifest.agent ?? agent, scope);
938
+
939
+ // Prune the now-empty .plain-forge directory and the agent directory if
940
+ // nothing else remains in it.
941
+ removeEmptyDirsUpward(
942
+ path.join(baseDir, path.dirname(MANIFEST_REL)),
943
+ baseDir,
944
+ );
945
+ // Stop at the agent dir's parent so only the agent dir itself is pruned
946
+ // (e.g. for opencode-global we remove ~/.config/opencode, never ~/.config).
947
+ removeEmptyDirsUpward(baseDir, path.dirname(baseDir));
576
948
  console.log();
577
949
  removed++;
578
950
  }
@@ -626,6 +998,8 @@ function agentLabel(agent) {
626
998
  return "Codex";
627
999
  case "forgecode":
628
1000
  return "ForgeCode";
1001
+ case "opencode":
1002
+ return "OpenCode";
629
1003
  case "universal":
630
1004
  return "AI coding agent";
631
1005
  default:
@@ -698,6 +1072,16 @@ export {
698
1072
  isUpToDate,
699
1073
  copyTreeTracked,
700
1074
  writeContent,
1075
+ resolveBaseDir,
1076
+ opencodeConfigPath,
1077
+ opencodeRulesGlob,
1078
+ mergeOpencodeInstructions,
1079
+ unmergeOpencodeInstructions,
1080
+ usesAgentsMd,
1081
+ agentsMdPath,
1082
+ agentsMdRulesGlob,
1083
+ mergeAgentsMd,
1084
+ unmergeAgentsMd,
701
1085
  manifestPathFor,
702
1086
  readManifest,
703
1087
  writeManifest,
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Rules for writing ***definitions*** sections in .plain files
3
- globs: "**/*.plain"
3
+ paths:
4
+ - "**/*.plain"
4
5
  ---
5
6
 
6
7
  # Rules for writing `***definitions***`
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Rules for using exported_concepts in .plain files
3
- globs: "**/*.plain"
3
+ paths:
4
+ - "**/*.plain"
4
5
  ---
5
6
 
6
7
  # Rules for `exported_concepts`
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Rules for writing ***functional specs*** and ***acceptance tests*** in .plain files
3
- globs: "**/*.plain"
3
+ paths:
4
+ - "**/*.plain"
4
5
  ---
5
6
 
6
7
  # Rules for writing `***functional specs***`
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Rules for writing ***implementation reqs*** sections in .plain files
3
- globs: "**/*.plain"
3
+ paths:
4
+ - "**/*.plain"
4
5
  ---
5
6
 
6
7
  # Rules for writing `***implementation reqs***`
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Rules for creating and using import modules in .plain files
3
- globs: "**/*.plain"
3
+ paths:
4
+ - "**/*.plain"
4
5
  ---
5
6
 
6
7
  # Rules for import modules
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Rules for authoring the three test scripts that an embedded ***plain integration ships
3
- globs: "**/*.plain"
3
+ paths:
4
+ - "**/*.plain"
4
5
  ---
5
6
 
6
7
  # Rules for embedded-integration test scripts
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Rules for authoring ***plain specs for REST API integrations embedded into an existing host codebase
3
- globs: "**/*.plain"
3
+ paths:
4
+ - "**/*.plain"
4
5
  ---
5
6
 
6
7
  # Rules for **embedded** integration specs
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Rules for authoring ***plain specs for REST API integrations deployed standalone
3
- globs: "**/*.plain"
3
+ paths:
4
+ - "**/*.plain"
4
5
  ---
5
6
 
6
7
  # Rules for **standalone** integration specs
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Rules for authoring ***plain specs for REST API integrations
3
- globs: "**/*.plain"
3
+ paths:
4
+ - "**/*.plain"
4
5
  ---
5
6
 
6
7
  # Rules for writing integration specs in `***plain`
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Line-length and bullet-continuation rules for every section in .plain files
3
- globs: "**/*.plain"
3
+ paths:
4
+ - "**/*.plain"
4
5
  ---
5
6
 
6
7
  # Rules for line length in `.plain` files
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Rules for linking external resources from .plain specs
3
- globs: "**/*.plain"
3
+ paths:
4
+ - "**/*.plain"
4
5
  ---
5
6
 
6
7
  # Rules for linked resources in `.plain` files
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Rules for using required_concepts in .plain files
3
- globs: "**/*.plain"
3
+ paths:
4
+ - "**/*.plain"
4
5
  ---
5
6
 
6
7
  # Rules for `required_concepts`
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Rules for creating and using requires modules in .plain files
3
- globs: "**/*.plain"
3
+ paths:
4
+ - "**/*.plain"
4
5
  ---
5
6
 
6
7
  # Rules for requires modules
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Rules for writing ***test reqs*** sections in .plain files
3
- globs: "**/*.plain"
3
+ paths:
4
+ - "**/*.plain"
4
5
  ---
5
6
 
6
7
  # Rules for writing `***test reqs***`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plain-forge",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "description": "Conversational spec-writing tool for ***plain specification language",
5
5
  "type": "module",
6
6
  "engines": {