plain-forge 1.0.14 → 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***`
@@ -74,6 +75,5 @@ When writing or editing a `***functional specs***` section in a `.plain` file, a
74
75
  - All :Participant: members of the :Conversation: can see the new :Message:.
75
76
 
76
77
  ***acceptance tests***
77
- - Sending a :Message: to a :Conversation: with three participants should
78
- make the message visible to all three.
78
+ - Sending a :Message: to a :Conversation: with three participants should make the message visible to all three.
79
79
  ```
@@ -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
@@ -14,8 +15,8 @@ A linked resource **must not** be any of the following:
14
15
  1. **A folder / directory.** `[integrations](src/integrations/)`, `[host project](../host_project/)` are invalid — the renderer cannot ingest a directory. Pick the single most representative file inside (a `README.md`, an exemplar source file, a manifest) and link **that**.
15
16
  2. **A URL / external location.** `[Stripe docs](https://stripe.com/docs/api)`, any `http://` / `https://` / `ftp://` / `git://` / `s3://` / `gs://` target. Linked resources are local-file only. If a URL's content is essential, fetch it once, save the response to a text file under `resources/`, and link the saved file.
16
17
  3. **A binary file.** PNG, JPG, GIF, PDF, DOCX, XLSX, ZIP, MP3, MP4, compiled binaries (`.exe`, `.so`, `.class`, `.wasm`), and anything else that isn't human-readable text in its raw form. Binary content cannot be meaningfully consumed by the renderer — transcribe it into a text-based form first (a UI screenshot becomes a Markdown description, a PDF spec becomes a Markdown extract or the underlying JSON Schema / OpenAPI, an architecture diagram becomes a Mermaid block).
18
+ 4. **Another `.plain` file.** `[Auth Module](auth.plain)` is invalid. To reference another module, use the `require` or `import` frontmatter directives. Do not link it as a markdown resource; the `plain-dry-run` will flag this as a syntax error.
17
19
 
18
- If the markdown-link target ends with `/`, contains `://`, resolves to a directory, or has a binary extension, **stop** — it cannot be a linked resource. Convert it first.
19
20
 
20
21
  ## URLs and folder paths must not appear *anywhere* in `.plain` content
21
22
  - The constraint is not only about markdown links — URLs and folder paths must not appear **anywhere** in `.plain` content (concept body prose, functional-spec text, implementation reqs, test reqs)
@@ -31,15 +32,15 @@ If the markdown-link target ends with `/`, contains `://`, resolves to a directo
31
32
  ```plain
32
33
  ***definitions***
33
34
 
34
- - :TaskCreateRequest: is the JSON payload for creating a task, defined by
35
- [resources/task_create_request.schema.json](resources/task_create_request.schema.json).
36
- - :TasksAPI: is the public HTTP surface for tasks, defined by
37
- [resources/tasks_openapi.yaml](resources/tasks_openapi.yaml).
35
+ - :TaskCreateRequest: is the JSON payload for creating a task.
36
+ - It is defined by [resources/task_create_request.schema.json](resources/task_create_request.schema.json).
37
+ - :TasksAPI: is the public HTTP surface for tasks.
38
+ - It is defined by [resources/tasks_openapi.yaml](resources/tasks_openapi.yaml).
38
39
 
39
40
  ***functional specs***
40
41
 
41
- - :User: should be able to add :Task: by POSTing :TaskCreateRequest: to the
42
- `POST /tasks` endpoint of :TasksAPI:. The endpoint responds per :TasksAPI:.
42
+ - :User: should be able to add :Task: by POSTing :TaskCreateRequest: to the `POST /tasks` endpoint of :TasksAPI:.
43
+ - The endpoint responds per :TasksAPI:.
43
44
  ```
44
45
 
45
46
  ## Each linked resource is referenced from exactly one place
@@ -50,8 +51,8 @@ If the markdown-link target ends with `/`, contains `://`, resolves to a directo
50
51
  ```plain
51
52
  ***definitions***
52
53
 
53
- - :TaskModalSpec: is the user-interface contract for the task modal,
54
- fully described in [task_modal_specification.yaml](task_modal_specification.yaml).
54
+ - :TaskModalSpec: is the user-interface contract for the task modal.
55
+ - It is fully described in [task_modal_specification.yaml](task_modal_specification.yaml).
55
56
 
56
57
  ***functional specs***
57
58
 
@@ -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***`
@@ -26,17 +26,16 @@ Use standard markdown link syntax inside any spec section:
26
26
 
27
27
  ```plain
28
28
  ***definitions***
29
- - :AuthData: is the authentication data structure. Its format is defined
30
- in the [auth schema](resources/auth_schema.json).
29
+ - :AuthData: is the authentication data structure.
30
+ - Its format is defined in the [auth schema](resources/auth_schema.json).
31
31
 
32
32
  ***implementation reqs***
33
- - When transforming :BackupData:, use the JOLT transform defined in
34
- [backup_transform.jolt](resources/backup_transform.jolt).
33
+ - When transforming :BackupData:, use the JOLT transform.
34
+ - The transform is defined in [backup_transform.jolt](resources/backup_transform.jolt).
35
35
 
36
36
  ***functional specs***
37
37
 
38
- - The system should expose an API conforming to the
39
- [API specification](resources/api_spec.yaml).
38
+ - The system should expose an API conforming to the [API specification](resources/api_spec.yaml).
40
39
  ```
41
40
 
42
41
  ## Path Rules
@@ -69,10 +69,9 @@ Too complex:
69
69
 
70
70
  Acceptable (separate the modes):
71
71
  - The system should process standard :Order: by validating and storing it.
72
- - The system should process express :Order: by queuing it for immediate dispatch
73
- without validation.
74
- - The system should process bulk :Order: by splitting it into sub-orders of 100
75
- items each and processing them individually.
72
+ - The system should process express :Order: by queuing it for immediate dispatch without validation.
73
+ - The system should process bulk :Order: by splitting it into sub-orders of 100 items each.
74
+ - Each sub-order is processed individually.
76
75
  ```
77
76
 
78
77
  ### 4. Cross-Cutting Concerns
@@ -88,8 +87,7 @@ Too complex:
88
87
  Acceptable (separate concerns):
89
88
  - The system should fetch :Resource: items from the external API.
90
89
  - The system should paginate when fetching :Resource: items from the external API.
91
- - The system should retry fetching :Resource: on transient errors using
92
- exponential backoff.
90
+ - The system should retry fetching :Resource: on transient errors using exponential backoff.
93
91
  ```
94
92
 
95
93
  ### 5. UI Complexity
@@ -111,10 +111,10 @@ If the spec describes different modes or branches, give each its own spec.
111
111
  **After:**
112
112
  ```plain
113
113
  - The system should generate a standard :MealPlan: using round-robin :Recipe: assignment.
114
- - The system should generate a restrictive :MealPlan: by filtering out excluded
115
- :Ingredient: items before applying round-robin assignment.
116
- - The system should allow :User: to manually assign :Recipe: items to individual
117
- :Slot: items for a custom :MealPlan:.
114
+ - The system should generate a restrictive :MealPlan:.
115
+ - Excluded :Ingredient: items are filtered out first.
116
+ - Round-robin :Recipe: assignment is then applied.
117
+ - The system should allow :User: to manually assign :Recipe: items to :Slot: items for a custom :MealPlan:.
118
118
  ```
119
119
 
120
120
  ### Strategy 5: Build UI Incrementally
@@ -184,8 +184,8 @@ The frontmatter is enclosed between `---` markers and supports:
184
184
  Specifications can reference external files using markdown link syntax. The linked resource is passed along with the spec to the renderer. File paths are resolved relative to the `.plain` file location. Only files in the same folder (and subfolders) are supported.
185
185
 
186
186
  ```plain
187
- - :User: should be able to add :Task:. The details of the user interface
188
- are provided in the file [task_modal_specification.yaml](task_modal_specification.yaml).
187
+ - :User: should be able to add :Task:.
188
+ - The user-interface details are in [task_modal_specification.yaml](task_modal_specification.yaml).
189
189
  ```
190
190
 
191
191
  #### Hard constraint: a linked resource is always a single, text-based file on disk
@@ -220,15 +220,15 @@ The accompanying spec line should describe the *role* of the artifact ("the requ
220
220
  ```plain
221
221
  ***definitions***
222
222
 
223
- - :TaskCreateRequest: is the JSON payload for creating a task, defined by
224
- [resources/task_create_request.schema.json](resources/task_create_request.schema.json).
225
- - :TasksAPI: is the public HTTP surface for tasks, defined by
226
- [resources/tasks_openapi.yaml](resources/tasks_openapi.yaml).
223
+ - :TaskCreateRequest: is the JSON payload for creating a task.
224
+ - It is defined by [resources/task_create_request.schema.json](resources/task_create_request.schema.json).
225
+ - :TasksAPI: is the public HTTP surface for tasks.
226
+ - It is defined by [resources/tasks_openapi.yaml](resources/tasks_openapi.yaml).
227
227
 
228
228
  ***functional specs***
229
229
 
230
- - :User: should be able to add :Task: by POSTing :TaskCreateRequest: to the
231
- `POST /tasks` endpoint of :TasksAPI:. The endpoint responds per :TasksAPI:.
230
+ - :User: should be able to add :Task: by POSTing :TaskCreateRequest: to the `POST /tasks` endpoint of :TasksAPI:.
231
+ - The endpoint responds per :TasksAPI:.
232
232
  ```
233
233
 
234
234
  **Each linked resource must be referenced from exactly one place** in the project — a single functional spec, implementation requirement, or `***definitions***` entry. Linking the same file from two functional specs (or from a functional spec *and* a requirement, etc.) creates two independent sources of truth: any later edit to one site silently diverges from the other, and the renderer has no way to know which one is canonical.
@@ -243,8 +243,8 @@ For example, instead of linking `task_modal_specification.yaml` from two differe
243
243
  ```plain
244
244
  ***definitions***
245
245
 
246
- - :TaskModalSpec: is the user-interface contract for the task modal,
247
- fully described in [task_modal_specification.yaml](task_modal_specification.yaml).
246
+ - :TaskModalSpec: is the user-interface contract for the task modal.
247
+ - It is fully described in [task_modal_specification.yaml](task_modal_specification.yaml).
248
248
 
249
249
  ***functional specs***
250
250
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plain-forge",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "Conversational spec-writing tool for ***plain specification language",
5
5
  "type": "module",
6
6
  "engines": {