@izkac/forgekit 0.3.12 → 0.3.13

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.
Files changed (116) hide show
  1. package/bin/forge.mjs +107 -107
  2. package/bin/forgekit.mjs +83 -83
  3. package/bin/review.mjs +81 -81
  4. package/package.json +1 -1
  5. package/scripts/prepack.mjs +78 -78
  6. package/scripts/run-tests.mjs +50 -50
  7. package/src/adr.mjs +236 -236
  8. package/src/adr.test.mjs +170 -170
  9. package/src/change.mjs +327 -234
  10. package/src/change.test.mjs +145 -83
  11. package/src/cleanup-sessions.mjs +84 -84
  12. package/src/config.mjs +103 -103
  13. package/src/defer.mjs +75 -75
  14. package/src/doctor.mjs +350 -341
  15. package/src/doctor.test.mjs +114 -114
  16. package/src/init.mjs +680 -621
  17. package/src/install.mjs +815 -815
  18. package/src/install.test.mjs +180 -180
  19. package/src/integrity-check.mjs +60 -60
  20. package/src/integrity.mjs +682 -682
  21. package/src/integrity.test.mjs +566 -566
  22. package/src/lib.mjs +143 -143
  23. package/src/models.defaults.json +41 -41
  24. package/src/new-session.mjs +99 -99
  25. package/src/openspec-overlays/README.md +19 -19
  26. package/src/openspec-overlays/openspec-apply-change-footer.md +14 -14
  27. package/src/openspec-overlays/opsx-apply-completion-step.md +1 -1
  28. package/src/openspec-overlays/opsx-apply-implement-step.md +11 -11
  29. package/src/paths.mjs +92 -92
  30. package/src/plan-engine.mjs +321 -278
  31. package/src/plan-engine.test.mjs +447 -283
  32. package/src/preferences.defaults.json +78 -78
  33. package/src/preferences.mjs +438 -438
  34. package/src/preferences.test.mjs +174 -174
  35. package/src/record-evidence.mjs +204 -204
  36. package/src/resolve-model.mjs +312 -312
  37. package/src/resolve-model.test.mjs +194 -194
  38. package/src/review/cli.test.mjs +117 -117
  39. package/src/review/export.mjs +172 -172
  40. package/src/review/export.test.mjs +197 -197
  41. package/src/review/fixtures/valid-review.json +42 -42
  42. package/src/review/lib.mjs +894 -894
  43. package/src/review/lib.test.mjs +266 -266
  44. package/src/review/schema.json +196 -196
  45. package/src/review/signals.test.mjs +62 -62
  46. package/src/score-cli.mjs +68 -68
  47. package/src/score.mjs +568 -568
  48. package/src/score.test.mjs +366 -366
  49. package/src/session-reminder.mjs +207 -207
  50. package/src/session-status.mjs +70 -70
  51. package/src/set-models.mjs +186 -186
  52. package/src/set-phase.mjs +205 -205
  53. package/src/set-prefs.mjs +294 -294
  54. package/src/specs-sync.mjs +234 -0
  55. package/src/specs-sync.test.mjs +114 -0
  56. package/src/spine.mjs +93 -93
  57. package/src/triage-prompt.mjs +175 -175
  58. package/src/triage-prompt.test.mjs +50 -50
  59. package/src/vendor-openspec-overlays.mjs +176 -176
  60. package/src/vendor-openspec-overlays.test.mjs +62 -62
  61. package/vendor/skills/archive-to-adr/SKILL.md +149 -149
  62. package/vendor/skills/forge/SKILL.md +136 -136
  63. package/vendor/skills/forge/docs/forge.md +650 -647
  64. package/vendor/skills/forge/phases/brainstorm.md +23 -23
  65. package/vendor/skills/forge/phases/finish.md +90 -87
  66. package/vendor/skills/forge/phases/implement.md +77 -77
  67. package/vendor/skills/forge/phases/plan-openspec.md +60 -60
  68. package/vendor/skills/forge/phases/plan-specs.md +163 -117
  69. package/vendor/skills/forge/phases/review.md +25 -25
  70. package/vendor/skills/forge/phases/verify.md +124 -124
  71. package/vendor/skills/forge/references/forge-layout.md +85 -85
  72. package/vendor/skills/forge/references/pace.md +115 -115
  73. package/vendor/skills/forge/references/plan-routing.md +52 -51
  74. package/vendor/skills/forge/references/runtime-integrity.md +232 -232
  75. package/vendor/skills/forge/references/substantial-work.md +37 -37
  76. package/vendor/skills/forge/references/test-evidence.md +30 -30
  77. package/vendor/skills/forge/references/test-strategy.md +68 -68
  78. package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -87
  79. package/vendor/skills/forge/subagents/final-reviewer-prompt.md +56 -56
  80. package/vendor/skills/forge/subagents/implementer-prompt.md +38 -38
  81. package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -132
  82. package/vendor/skills/thorough-code-review/SKILL.md +290 -290
  83. package/vendor/skills/thorough-code-review/examples.md +133 -133
  84. package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -26
  85. package/vendor/skills/thorough-code-review/reference/lenses.md +96 -96
  86. package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -62
  87. package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -105
  88. package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -222
  89. package/vendor/skills/thorough-code-review/reference/report-template.md +115 -115
  90. package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -49
  91. package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -55
  92. package/vendor/templates/adr/README.md +7 -7
  93. package/vendor/templates/adr/decisions.md +141 -141
  94. package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -74
  95. package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -3
  96. package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -52
  97. package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -3
  98. package/vendor/templates/project/claude/commands/forge-apply.md +75 -75
  99. package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -7
  100. package/vendor/templates/project/claude/commands/forge-build.md +17 -17
  101. package/vendor/templates/project/claude/commands/forge-plan.md +12 -12
  102. package/vendor/templates/project/claude/commands/forge-skip.md +14 -14
  103. package/vendor/templates/project/claude/commands/forge-status.md +16 -16
  104. package/vendor/templates/project/claude/commands/forge.md +16 -16
  105. package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -73
  106. package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -19
  107. package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -77
  108. package/vendor/templates/project/cursor/commands/forge-apply.md +75 -75
  109. package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -10
  110. package/vendor/templates/project/cursor/commands/forge-build.md +17 -17
  111. package/vendor/templates/project/cursor/commands/forge-plan.md +15 -15
  112. package/vendor/templates/project/cursor/commands/forge-skip.md +14 -14
  113. package/vendor/templates/project/cursor/commands/forge-status.md +16 -16
  114. package/vendor/templates/project/cursor/commands/forge.md +16 -16
  115. package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -30
  116. package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -3
package/src/init.mjs CHANGED
@@ -1,621 +1,680 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Wire Forge into a project: commands, thin rules, hooks, .forge gitignore.
4
- *
5
- * Usage:
6
- * forge init # interactive agent picker
7
- * forge init --cursor --claude
8
- * forge init --all
9
- * forge init --overlay # also apply OpenSpec vendor overlays
10
- */
11
-
12
- import fs from 'node:fs';
13
- import path from 'node:path';
14
- import { fileURLToPath, pathToFileURL } from 'node:url';
15
- import { spawnSync } from 'node:child_process';
16
- import { checkbox, confirm, input } from '@inquirer/prompts';
17
- import {
18
- DEFAULT_ADR_DIR,
19
- disableProjectAdr,
20
- loadUserConfig,
21
- normalizeAdrDir,
22
- scaffoldAdr,
23
- } from './adr.mjs';
24
- import {
25
- DEFAULT_SPECS_DIR,
26
- hasOpenSpecConfig,
27
- loadUserPlanEngine,
28
- scaffoldSpecs,
29
- setupOpenSpec,
30
- writeProjectPlanConfig,
31
- } from './plan-engine.mjs';
32
- import { resolveAsset } from './paths.mjs';
33
- import { AGENT_IDS, AGENTS, installedManagedPairs } from './install.mjs';
34
-
35
- // Environments with project-local command/rule/hook templates. Others are
36
- // driven by the globally-installed skill alone (no per-project wiring).
37
- const WIRED_AGENTS = Object.freeze(['cursor', 'claude', 'codex']);
38
-
39
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
40
-
41
- /**
42
- * @param {string[]} argv
43
- */
44
- export function parseArgs(argv) {
45
- const opts = {
46
- all: false,
47
- help: false,
48
- force: false,
49
- overlay: false,
50
- agents: /** @type {string[]} */ ([]),
51
- cwd: process.cwd(),
52
- /** @type {boolean | null} */
53
- adr: /** @type {boolean | null} */ (null),
54
- adrDir: /** @type {string | null} */ (null),
55
- /** @type {boolean | null} true=openspec, false=specs, null=detect/prompt */
56
- openspec: /** @type {boolean | null} */ (null),
57
- };
58
-
59
- for (let i = 0; i < argv.length; i += 1) {
60
- const arg = argv[i];
61
- if (arg === '--all') opts.all = true;
62
- else if (arg === '--force' || arg === '-f') opts.force = true;
63
- else if (arg === '--overlay') opts.overlay = true;
64
- else if (arg === '--cwd') opts.cwd = argv[++i];
65
- else if (arg === '--help' || arg === '-h') opts.help = true;
66
- else if (arg === '--cursor') opts.agents.push('cursor');
67
- else if (arg === '--claude' || arg === '--claude-code') opts.agents.push('claude');
68
- else if (arg === '--codex') opts.agents.push('codex');
69
- else if (arg === '--copilot') opts.agents.push('copilot');
70
- else if (arg === '--gemini') opts.agents.push('gemini');
71
- else if (arg === '--windsurf') opts.agents.push('windsurf');
72
- else if (arg === '--opencode') opts.agents.push('opencode');
73
- else if (arg === '--adr') opts.adr = true;
74
- else if (arg === '--no-adr') opts.adr = false;
75
- else if (arg === '--adr-dir') opts.adrDir = argv[++i];
76
- else if (arg === '--openspec') opts.openspec = true;
77
- else if (arg === '--no-openspec') opts.openspec = false;
78
- else throw new Error(`Unknown argument: ${arg}`);
79
- }
80
-
81
- return opts;
82
- }
83
-
84
- function printHelp() {
85
- process.stdout.write(`Usage: forge init [options]
86
-
87
- Wire Forge commands, thin rules, and hooks into the current project.
88
-
89
- Options:
90
- --cursor Cursor (.cursor/commands, rules, hooks)
91
- --claude Claude Code (.claude/commands, rules, hooks)
92
- --codex Codex CLI (.codex/rules)
93
- --copilot/--gemini/--windsurf/--opencode
94
- Offered in the picker for parity with \`forgekit install\`;
95
- driven by the global skill (no per-project wiring yet)
96
- --all Every offered environment
97
- --openspec Plan with OpenSpec (offer install + \`openspec init\` if missing)
98
- --no-openspec Plan with the built-in specs engine (${DEFAULT_SPECS_DIR}/changes/)
99
- --adr Enable ADRs (scaffold decisions.md + ADR dir + hooks)
100
- --no-adr Disable ADRs for this project
101
- --adr-dir <path> ADR directory (default: ${DEFAULT_ADR_DIR} or ~/.forgekit preference)
102
- --overlay Also run \`forge overlay\` (OpenSpec vendor patches)
103
- --force, -f Force re-scaffold of ADR/specs docs (managed command,
104
- rule, and hook files always refresh to the latest template)
105
- --cwd <path> Project root (default: cwd)
106
- --help
107
-
108
- Requires the Forge skill already installed (\`forge install\`) for agents
109
- to load skill content. Init only adds project-local wiring.
110
-
111
- Interactive (TTY): the environment picker matches \`forgekit install\` and is
112
- pre-checked with what you installed there (saved in ~/.forgekit/config.json),
113
- so you don't pick twice. When --openspec/--no-openspec omitted and OpenSpec is
114
- not already set up, offers to install + set it up (decline = built-in specs
115
- engine). When --adr/--no-adr omitted, asks whether to use ADRs (default Yes)
116
- and for the directory inside the repo.
117
- `);
118
- }
119
-
120
- /**
121
- * @returns {string}
122
- */
123
- export function resolveTemplatesRoot() {
124
- return resolveAsset('templates/project');
125
- }
126
-
127
- /**
128
- * Copy a forgekit-managed template file. These are regenerated pointers
129
- * (forge-* commands/rules/hooks) with no user-owned content, so re-running
130
- * `forge init` refreshes them in place — that's how template fixes propagate.
131
- * @param {string} src
132
- * @param {string} dest
133
- * @param {{ force?: boolean }} _opts
134
- */
135
- function copyFile(src, dest, _opts) {
136
- fs.mkdirSync(path.dirname(dest), { recursive: true });
137
- const next = fs.readFileSync(src);
138
- if (fs.existsSync(dest)) {
139
- if (fs.readFileSync(dest).equals(next)) return 'unchanged';
140
- fs.writeFileSync(dest, next);
141
- return 'updated';
142
- }
143
- fs.writeFileSync(dest, next);
144
- return 'written';
145
- }
146
-
147
- /**
148
- * @param {string} srcDir
149
- * @param {string} destDir
150
- * @param {{ force?: boolean }} opts
151
- */
152
- function copyDirFiles(srcDir, destDir, opts) {
153
- /** @type {{ file: string, status: string }[]} */
154
- const out = [];
155
- if (!fs.existsSync(srcDir)) return out;
156
- for (const name of fs.readdirSync(srcDir)) {
157
- const from = path.join(srcDir, name);
158
- if (!fs.statSync(from).isFile()) continue;
159
- const to = path.join(destDir, name);
160
- out.push({ file: path.relative(opts.cwd ?? destDir, to) || to, status: copyFile(from, to, opts) });
161
- }
162
- return out;
163
- }
164
-
165
- /**
166
- * Ensure .forge/.gitignore exists.
167
- * @param {string} cwd
168
- */
169
- export function ensureForgeGitignore(cwd) {
170
- const forgeDir = path.join(cwd, '.forge');
171
- fs.mkdirSync(forgeDir, { recursive: true });
172
- const gi = path.join(forgeDir, '.gitignore');
173
- const body = `# Forge session scratch — keep layout docs + committed project config
174
- *
175
- !.gitignore
176
- !README.md
177
- !config.json
178
- `;
179
- if (!fs.existsSync(gi)) {
180
- fs.writeFileSync(gi, body, 'utf8');
181
- return 'written';
182
- }
183
- // Upgrade older scaffolds that omit config.json
184
- const existing = fs.readFileSync(gi, 'utf8');
185
- if (!existing.includes('!config.json')) {
186
- const next = existing.trimEnd().endsWith('!README.md')
187
- ? `${existing.trimEnd()}\n!config.json\n`
188
- : `${existing.trimEnd()}\n!config.json\n`;
189
- fs.writeFileSync(gi, next, 'utf8');
190
- return 'updated';
191
- }
192
- return 'exists';
193
- }
194
-
195
- /**
196
- * Write a short project README under .forge/ if missing.
197
- * @param {string} cwd
198
- */
199
- export function ensureForgeReadme(cwd) {
200
- const readme = path.join(cwd, '.forge', 'README.md');
201
- if (fs.existsSync(readme)) return 'exists';
202
- fs.writeFileSync(
203
- readme,
204
- `# \`.forge/\` — Forge session scratch
205
-
206
- Per-checkout, **gitignored** workspace for Forge session orchestration.
207
- Canonical specs live in \`openspec/\`; this directory holds session-local artefacts only.
208
-
209
- \`\`\`bash
210
- forge new <slug>
211
- forge status
212
- forge prefs
213
- forge models
214
- forge cleanup
215
- \`\`\`
216
-
217
- See the Forge skill and forgekit docs for the full workflow.
218
- `,
219
- 'utf8',
220
- );
221
- return 'written';
222
- }
223
-
224
- /**
225
- * Append hook registrations into Claude settings.json if present / create stub note.
226
- * @param {string} cwd
227
- * @param {{ force?: boolean }} opts
228
- */
229
- export function ensureClaudeHookHints(cwd, opts) {
230
- const settingsPath = path.join(cwd, '.claude', 'settings.json');
231
- const notePath = path.join(cwd, '.claude', 'forge-hooks.snippet.json');
232
- const snippet = {
233
- _comment:
234
- 'Merge these hooks into .claude/settings.json (SessionStart + UserPromptSubmit). Paths assume forge CLI is on PATH.',
235
- hooks: {
236
- SessionStart: [
237
- {
238
- hooks: [
239
- {
240
- type: 'command',
241
- command: 'node "${CLAUDE_PROJECT_DIR}/.claude/hooks/forge-session-start.mjs"',
242
- },
243
- ],
244
- },
245
- ],
246
- UserPromptSubmit: [
247
- {
248
- hooks: [
249
- {
250
- type: 'command',
251
- command: 'node "${CLAUDE_PROJECT_DIR}/.claude/hooks/forge-triage-hook.mjs"',
252
- },
253
- {
254
- type: 'command',
255
- command: 'node "${CLAUDE_PROJECT_DIR}/.claude/hooks/forge-prompt-hook.mjs"',
256
- },
257
- ],
258
- },
259
- ],
260
- },
261
- };
262
- fs.mkdirSync(path.dirname(notePath), { recursive: true });
263
- if (!fs.existsSync(notePath) || opts.force) {
264
- fs.writeFileSync(notePath, `${JSON.stringify(snippet, null, 2)}\n`, 'utf8');
265
- }
266
- return {
267
- settingsExists: fs.existsSync(settingsPath),
268
- snippet: notePath,
269
- };
270
- }
271
-
272
- /**
273
- * Write Cursor hooks.json snippet.
274
- * @param {string} cwd
275
- * @param {{ force?: boolean }} opts
276
- */
277
- export function ensureCursorHookHints(cwd, opts) {
278
- const notePath = path.join(cwd, '.cursor', 'forge-hooks.snippet.json');
279
- const snippet = {
280
- _comment:
281
- 'Merge into .cursor/hooks.json. Requires Node on PATH (forge-session-start.mjs).',
282
- version: 1,
283
- hooks: {
284
- sessionStart: [
285
- {
286
- command: 'node .cursor/hooks/forge-session-start.mjs',
287
- },
288
- ],
289
- },
290
- };
291
- fs.mkdirSync(path.dirname(notePath), { recursive: true });
292
- if (!fs.existsSync(notePath) || opts.force) {
293
- fs.writeFileSync(notePath, `${JSON.stringify(snippet, null, 2)}\n`, 'utf8');
294
- }
295
- return notePath;
296
- }
297
-
298
- /**
299
- * @param {string[]} selected
300
- * @param {{ cwd: string, force?: boolean, overlay?: boolean, templatesRoot?: string, adr?: boolean | null, adrDir?: string | null, home?: string, planEngine?: string | null }} opts
301
- */
302
- export function initProject(selected, opts) {
303
- const templates = opts.templatesRoot ?? resolveTemplatesRoot();
304
- const cwd = opts.cwd;
305
- /** @type {Record<string, unknown>} */
306
- const report = {
307
- cwd,
308
- gitignore: ensureForgeGitignore(cwd),
309
- readme: ensureForgeReadme(cwd),
310
- files: /** @type {{ file: string, status: string }[]} */ ([]),
311
- };
312
-
313
- const copyOpts = { force: opts.force, cwd };
314
-
315
- if (selected.includes('cursor')) {
316
- report.files.push(
317
- ...copyDirFiles(
318
- path.join(templates, 'cursor', 'commands'),
319
- path.join(cwd, '.cursor', 'commands'),
320
- copyOpts,
321
- ),
322
- ...copyDirFiles(
323
- path.join(templates, 'cursor', 'rules'),
324
- path.join(cwd, '.cursor', 'rules'),
325
- copyOpts,
326
- ),
327
- ...copyDirFiles(
328
- path.join(templates, 'cursor', 'hooks'),
329
- path.join(cwd, '.cursor', 'hooks'),
330
- copyOpts,
331
- ),
332
- );
333
- report.cursorHookSnippet = ensureCursorHookHints(cwd, copyOpts);
334
- }
335
-
336
- if (selected.includes('claude')) {
337
- report.files.push(
338
- ...copyDirFiles(
339
- path.join(templates, 'claude', 'commands'),
340
- path.join(cwd, '.claude', 'commands'),
341
- copyOpts,
342
- ),
343
- ...copyDirFiles(
344
- path.join(templates, 'claude', 'rules'),
345
- path.join(cwd, '.claude', 'rules'),
346
- copyOpts,
347
- ),
348
- ...copyDirFiles(
349
- path.join(templates, 'claude', 'hooks'),
350
- path.join(cwd, '.claude', 'hooks'),
351
- copyOpts,
352
- ),
353
- );
354
- report.claudeHooks = ensureClaudeHookHints(cwd, copyOpts);
355
- }
356
-
357
- if (selected.includes('codex')) {
358
- report.files.push(
359
- ...copyDirFiles(
360
- path.join(templates, 'codex', 'rules'),
361
- path.join(cwd, '.codex', 'rules'),
362
- copyOpts,
363
- ),
364
- );
365
- }
366
-
367
- // Selected environments without project-wiring templates: the globally
368
- // installed skill is their interface — nothing to scaffold per project.
369
- report.skillOnly = selected.filter((id) => !WIRED_AGENTS.includes(id));
370
-
371
- if (opts.planEngine === 'specs') {
372
- const scaffold = scaffoldSpecs(cwd, { force: opts.force });
373
- const config = writeProjectPlanConfig(cwd, {
374
- engine: 'specs',
375
- dir: scaffold.dir,
376
- });
377
- report.plan = { engine: 'specs', dir: scaffold.dir, files: scaffold.files, config };
378
- } else if (opts.planEngine === 'openspec') {
379
- const config = writeProjectPlanConfig(cwd, { engine: 'openspec' });
380
- report.plan = {
381
- engine: 'openspec',
382
- configured: hasOpenSpecConfig(cwd),
383
- config,
384
- };
385
- }
386
-
387
- if (opts.adr === true) {
388
- const user = loadUserConfig(opts.home);
389
- const dir = normalizeAdrDir(
390
- opts.adrDir ?? user.adr?.dir ?? DEFAULT_ADR_DIR,
391
- );
392
- report.adr = scaffoldAdr(cwd, {
393
- dir,
394
- force: opts.force,
395
- hooks: true,
396
- });
397
- } else if (opts.adr === false) {
398
- report.adr = { config: disableProjectAdr(cwd), enabled: false };
399
- }
400
-
401
- if (opts.overlay) {
402
- const overlayScript = path.join(__dirname, 'vendor-openspec-overlays.mjs');
403
- const r = spawnSync(process.execPath, [overlayScript], {
404
- cwd,
405
- encoding: 'utf8',
406
- });
407
- report.overlay = {
408
- status: r.status,
409
- stdout: r.stdout?.trim() || '',
410
- stderr: r.stderr?.trim() || '',
411
- };
412
- }
413
-
414
- return report;
415
- }
416
-
417
- /**
418
- * Environments this project already has Forge wiring for (marker dir present).
419
- * @param {string} cwd
420
- * @returns {Set<string>}
421
- */
422
- function wiredAgents(cwd) {
423
- const markers = {
424
- cursor: path.join(cwd, '.cursor', 'commands'),
425
- claude: path.join(cwd, '.claude', 'commands'),
426
- codex: path.join(cwd, '.codex', 'rules'),
427
- };
428
- return new Set(
429
- Object.entries(markers)
430
- .filter(([, dir]) => fs.existsSync(dir))
431
- .map(([id]) => id),
432
- );
433
- }
434
-
435
- /**
436
- * Environments to pre-check: those chosen during `forgekit install`
437
- * (saved in ~/.forgekit/config.json), plus what is already installed or wired.
438
- * @param {string} cwd
439
- * @param {string} [home]
440
- * @returns {Set<string>}
441
- */
442
- export function rememberedAgents(cwd, home) {
443
- const user = loadUserConfig(home);
444
- return new Set([
445
- ...(Array.isArray(user.agents) ? user.agents : []),
446
- ...installedManagedPairs(home).map((p) => p.agent),
447
- ...wiredAgents(cwd),
448
- ]);
449
- }
450
-
451
- /** @param {string} cwd */
452
- async function promptAgents(cwd) {
453
- const remembered = rememberedAgents(cwd);
454
- return checkbox({
455
- message: 'Init Forge project wiring for which environments?',
456
- choices: AGENT_IDS.map((id) => ({
457
- value: id,
458
- name: AGENTS[id].label,
459
- checked: remembered.has(id),
460
- })),
461
- required: true,
462
- });
463
- }
464
-
465
- /**
466
- * Offer to install + set up OpenSpec in this project.
467
- * @returns {Promise<boolean>} true = user accepted OpenSpec setup
468
- */
469
- async function promptOpenSpecSetup() {
470
- return confirm({
471
- message:
472
- 'OpenSpec is not set up in this project. Install and set it up now? (No = built-in specs engine)',
473
- default: true,
474
- });
475
- }
476
-
477
- /**
478
- * Resolve the planning engine for `forge init`, offering OpenSpec setup when needed.
479
- * @param {{ cwd: string, openspec: boolean | null, agents?: string[] }} opts
480
- * @returns {Promise<string>} 'openspec' | 'specs'
481
- */
482
- async function resolveInitPlanEngine(opts) {
483
- const configured = hasOpenSpecConfig(opts.cwd);
484
- const tools = opts.agents;
485
-
486
- if (opts.openspec === false) return 'specs';
487
-
488
- if (opts.openspec === true) {
489
- if (!configured && process.stdin.isTTY) {
490
- const setup = setupOpenSpec(opts.cwd, { tools });
491
- for (const s of setup.steps) {
492
- process.stdout.write(` [${s.ok ? 'ok' : 'FAIL'}] ${s.step}${s.detail ? ` — ${s.detail}` : ''}\n`);
493
- }
494
- if (!setup.ok) {
495
- process.stderr.write(
496
- 'OpenSpec setup failed — engine recorded as openspec; re-run `forge doctor --install` or `openspec init` manually.\n',
497
- );
498
- }
499
- }
500
- return 'openspec';
501
- }
502
-
503
- if (configured) return 'openspec';
504
-
505
- const userDefault = loadUserPlanEngine();
506
- if (userDefault === 'specs') return 'specs';
507
-
508
- // Default (or user prefers openspec) but project has no OpenSpec yet
509
- if (!process.stdin.isTTY) {
510
- return userDefault === 'openspec' ? 'openspec' : 'specs';
511
- }
512
-
513
- const accepted = await promptOpenSpecSetup();
514
- if (!accepted) return 'specs';
515
-
516
- const setup = setupOpenSpec(opts.cwd, { tools });
517
- for (const s of setup.steps) {
518
- process.stdout.write(` [${s.ok ? 'ok' : 'FAIL'}] ${s.step}${s.detail ? ` — ${s.detail}` : ''}\n`);
519
- }
520
- if (!setup.ok) {
521
- process.stderr.write(
522
- 'OpenSpec setup failed — falling back to the built-in specs engine. You can switch later with `forge init --openspec --force`.\n',
523
- );
524
- return 'specs';
525
- }
526
- return 'openspec';
527
- }
528
-
529
- /**
530
- * @param {string} [defaultDir]
531
- * @param {boolean} [defaultEnabled]
532
- * @returns {Promise<{ enabled: boolean, dir: string }>}
533
- */
534
- async function promptAdrForInit(defaultDir = DEFAULT_ADR_DIR, defaultEnabled = true) {
535
- const enabled = await confirm({
536
- message: 'Use Architecture Decision Records (ADRs) in this project?',
537
- default: defaultEnabled,
538
- });
539
- if (!enabled) return { enabled: false, dir: defaultDir };
540
- const dir = await input({
541
- message: 'ADR directory inside the repo',
542
- default: defaultDir,
543
- });
544
- return { enabled: true, dir: normalizeAdrDir(dir.trim() || defaultDir) };
545
- }
546
-
547
- async function main(argv = process.argv.slice(2)) {
548
- const opts = parseArgs(argv);
549
- if (opts.help) {
550
- printHelp();
551
- return 0;
552
- }
553
-
554
- let selected = opts.all ? [...AGENT_IDS] : [...new Set(opts.agents)];
555
- if (selected.length === 0) {
556
- if (!process.stdin.isTTY) {
557
- process.stderr.write(
558
- 'No agents specified. Pass --cursor/--claude/--codex/--copilot/--gemini/--windsurf/--opencode/--all, or run in a TTY.\n',
559
- );
560
- return 1;
561
- }
562
- selected = await promptAgents(opts.cwd);
563
- }
564
-
565
- for (const id of selected) {
566
- if (!AGENTS[id]) {
567
- process.stderr.write(`Unknown environment: ${id}. Known: ${AGENT_IDS.join(', ')}\n`);
568
- return 1;
569
- }
570
- }
571
-
572
- const planEngine = await resolveInitPlanEngine({
573
- cwd: opts.cwd,
574
- openspec: opts.openspec,
575
- agents: selected,
576
- });
577
-
578
- let adr = opts.adr;
579
- let adrDir = opts.adrDir;
580
- if (adr === null) {
581
- const user = loadUserConfig();
582
- const defaultDir = user.adr?.dir ?? DEFAULT_ADR_DIR;
583
- if (process.stdin.isTTY) {
584
- // Default Yes, unless the user globally opted out of ADRs.
585
- const picked = await promptAdrForInit(defaultDir, user.adr?.enabled !== false);
586
- adr = picked.enabled;
587
- adrDir = picked.dir;
588
- } else if (user.adr?.enabled === true) {
589
- adr = true;
590
- adrDir = user.adr.dir ?? DEFAULT_ADR_DIR;
591
- } else {
592
- adr = false;
593
- }
594
- }
595
-
596
- const report = initProject(selected, { ...opts, adr, adrDir, planEngine });
597
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
598
- if (Array.isArray(report.skillOnly) && report.skillOnly.length) {
599
- const labels = report.skillOnly.map((id) => AGENTS[id].label).join(', ');
600
- process.stdout.write(
601
- `\nNo project wiring for: ${labels} — they use the globally installed Forge skill directly (run \`forgekit install\` if not yet installed).\n`,
602
- );
603
- }
604
- process.stdout.write(
605
- `\nMerge hook snippets into settings if needed, ensure \`forge\` is on PATH, then open the project in your agent.\n`,
606
- );
607
- return 0;
608
- }
609
-
610
- const isDirect =
611
- process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
612
-
613
- if (isDirect) {
614
- main()
615
- .then((code) => process.exit(code))
616
- .catch((err) => {
617
- if (err?.name === 'ExitPromptError') process.exit(130);
618
- process.stderr.write(`${err.message || err}\n`);
619
- process.exit(1);
620
- });
621
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Wire Forge into a project: commands, thin rules, hooks, .forge gitignore.
4
+ *
5
+ * Usage:
6
+ * forge init # interactive agent picker
7
+ * forge init --cursor --claude
8
+ * forge init --all
9
+ * forge init --overlay # also apply OpenSpec vendor overlays
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath, pathToFileURL } from 'node:url';
15
+ import { spawnSync } from 'node:child_process';
16
+ import { checkbox, confirm, input } from '@inquirer/prompts';
17
+ import {
18
+ DEFAULT_ADR_DIR,
19
+ disableProjectAdr,
20
+ loadUserConfig,
21
+ normalizeAdrDir,
22
+ scaffoldAdr,
23
+ } from './adr.mjs';
24
+ import {
25
+ DEFAULT_SPECS_DIR,
26
+ hasOpenSpecConfig,
27
+ loadUserPlanEngine,
28
+ scaffoldSpecs,
29
+ setupOpenSpec,
30
+ writeProjectPlanConfig,
31
+ } from './plan-engine.mjs';
32
+ import { resolveAsset } from './paths.mjs';
33
+ import {
34
+ AGENT_IDS,
35
+ AGENTS,
36
+ installedManagedPairs,
37
+ promptOpenSpec,
38
+ } from './install.mjs';
39
+
40
+ // Environments with project-local command/rule/hook templates. Others are
41
+ // driven by the globally-installed skill alone (no per-project wiring).
42
+ const WIRED_AGENTS = Object.freeze(['cursor', 'claude', 'codex']);
43
+
44
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
45
+
46
+ /**
47
+ * @param {string[]} argv
48
+ */
49
+ export function parseArgs(argv) {
50
+ const opts = {
51
+ all: false,
52
+ help: false,
53
+ force: false,
54
+ overlay: false,
55
+ agents: /** @type {string[]} */ ([]),
56
+ cwd: process.cwd(),
57
+ /** @type {boolean | null} */
58
+ adr: /** @type {boolean | null} */ (null),
59
+ adrDir: /** @type {string | null} */ (null),
60
+ /** @type {boolean | null} true=openspec, false=specs, null=detect/prompt */
61
+ openspec: /** @type {boolean | null} */ (null),
62
+ /** @type {string | null} specs-engine root (plan.dir) */
63
+ planDir: /** @type {string | null} */ (null),
64
+ };
65
+
66
+ for (let i = 0; i < argv.length; i += 1) {
67
+ const arg = argv[i];
68
+ if (arg === '--all') opts.all = true;
69
+ else if (arg === '--force' || arg === '-f') opts.force = true;
70
+ else if (arg === '--overlay') opts.overlay = true;
71
+ else if (arg === '--cwd') opts.cwd = argv[++i];
72
+ else if (arg === '--help' || arg === '-h') opts.help = true;
73
+ else if (arg === '--cursor') opts.agents.push('cursor');
74
+ else if (arg === '--claude' || arg === '--claude-code') opts.agents.push('claude');
75
+ else if (arg === '--codex') opts.agents.push('codex');
76
+ else if (arg === '--copilot') opts.agents.push('copilot');
77
+ else if (arg === '--gemini') opts.agents.push('gemini');
78
+ else if (arg === '--windsurf') opts.agents.push('windsurf');
79
+ else if (arg === '--opencode') opts.agents.push('opencode');
80
+ else if (arg === '--adr') opts.adr = true;
81
+ else if (arg === '--no-adr') opts.adr = false;
82
+ else if (arg === '--adr-dir') opts.adrDir = argv[++i];
83
+ else if (arg === '--openspec') opts.openspec = true;
84
+ else if (arg === '--no-openspec') opts.openspec = false;
85
+ else if (arg === '--plan-dir') opts.planDir = argv[++i];
86
+ else throw new Error(`Unknown argument: ${arg}`);
87
+ }
88
+
89
+ return opts;
90
+ }
91
+
92
+ function printHelp() {
93
+ process.stdout.write(`Usage: forge init [options]
94
+
95
+ Wire Forge commands, thin rules, and hooks into the current project.
96
+
97
+ Options:
98
+ --cursor Cursor (.cursor/commands, rules, hooks)
99
+ --claude Claude Code (.claude/commands, rules, hooks)
100
+ --codex Codex CLI (.codex/rules)
101
+ --copilot/--gemini/--windsurf/--opencode
102
+ Offered in the picker for parity with \`forgekit install\`;
103
+ driven by the global skill (no per-project wiring yet)
104
+ --all Every offered environment
105
+ --openspec Plan with OpenSpec (offer install + \`openspec init\` if missing)
106
+ --no-openspec Plan with the built-in specs engine (${DEFAULT_SPECS_DIR}/changes/)
107
+ --plan-dir <path> Specs-engine root (plan.dir). Default: ${DEFAULT_SPECS_DIR}.
108
+ Use \`openspec\` to reuse an existing OpenSpec tree without moving files.
109
+ --adr Enable ADRs (scaffold decisions.md + ADR dir + hooks)
110
+ --no-adr Disable ADRs for this project
111
+ --adr-dir <path> ADR directory (default: ${DEFAULT_ADR_DIR} or ~/.forgekit preference)
112
+ --overlay Also run \`forge overlay\` (OpenSpec vendor patches)
113
+ --force, -f Force re-scaffold of ADR/specs docs (managed command,
114
+ rule, and hook files always refresh to the latest template)
115
+ --cwd <path> Project root (default: cwd)
116
+ --help
117
+
118
+ Requires the Forge skill already installed (\`forge install\`) for agents
119
+ to load skill content. Init only adds project-local wiring.
120
+
121
+ Interactive (TTY): the environment picker matches \`forgekit install\` and is
122
+ pre-checked with what you installed there (saved in ~/.forgekit/config.json),
123
+ so you don't pick twice. When --openspec/--no-openspec omitted: uses the user
124
+ default from install when set; otherwise asks Planning engine?. Choosing
125
+ OpenSpec always writes plan.engine=openspec (setup failure or declining
126
+ immediate \`openspec init\` does not fall back to the built-in specs engine).
127
+ When --adr/--no-adr omitted, asks whether to use ADRs (default Yes) and for
128
+ the directory inside the repo.
129
+ `);
130
+ }
131
+
132
+ /**
133
+ * @returns {string}
134
+ */
135
+ export function resolveTemplatesRoot() {
136
+ return resolveAsset('templates/project');
137
+ }
138
+
139
+ /**
140
+ * Copy a forgekit-managed template file. These are regenerated pointers
141
+ * (forge-* commands/rules/hooks) with no user-owned content, so re-running
142
+ * `forge init` refreshes them in place — that's how template fixes propagate.
143
+ * @param {string} src
144
+ * @param {string} dest
145
+ * @param {{ force?: boolean }} _opts
146
+ */
147
+ function copyFile(src, dest, _opts) {
148
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
149
+ const next = fs.readFileSync(src);
150
+ if (fs.existsSync(dest)) {
151
+ if (fs.readFileSync(dest).equals(next)) return 'unchanged';
152
+ fs.writeFileSync(dest, next);
153
+ return 'updated';
154
+ }
155
+ fs.writeFileSync(dest, next);
156
+ return 'written';
157
+ }
158
+
159
+ /**
160
+ * @param {string} srcDir
161
+ * @param {string} destDir
162
+ * @param {{ force?: boolean }} opts
163
+ */
164
+ function copyDirFiles(srcDir, destDir, opts) {
165
+ /** @type {{ file: string, status: string }[]} */
166
+ const out = [];
167
+ if (!fs.existsSync(srcDir)) return out;
168
+ for (const name of fs.readdirSync(srcDir)) {
169
+ const from = path.join(srcDir, name);
170
+ if (!fs.statSync(from).isFile()) continue;
171
+ const to = path.join(destDir, name);
172
+ out.push({ file: path.relative(opts.cwd ?? destDir, to) || to, status: copyFile(from, to, opts) });
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * Ensure .forge/.gitignore exists.
179
+ * @param {string} cwd
180
+ */
181
+ export function ensureForgeGitignore(cwd) {
182
+ const forgeDir = path.join(cwd, '.forge');
183
+ fs.mkdirSync(forgeDir, { recursive: true });
184
+ const gi = path.join(forgeDir, '.gitignore');
185
+ const body = `# Forge session scratch — keep layout docs + committed project config
186
+ *
187
+ !.gitignore
188
+ !README.md
189
+ !config.json
190
+ `;
191
+ if (!fs.existsSync(gi)) {
192
+ fs.writeFileSync(gi, body, 'utf8');
193
+ return 'written';
194
+ }
195
+ // Upgrade older scaffolds that omit config.json
196
+ const existing = fs.readFileSync(gi, 'utf8');
197
+ if (!existing.includes('!config.json')) {
198
+ const next = existing.trimEnd().endsWith('!README.md')
199
+ ? `${existing.trimEnd()}\n!config.json\n`
200
+ : `${existing.trimEnd()}\n!config.json\n`;
201
+ fs.writeFileSync(gi, next, 'utf8');
202
+ return 'updated';
203
+ }
204
+ return 'exists';
205
+ }
206
+
207
+ /**
208
+ * Write a short project README under .forge/ if missing.
209
+ * @param {string} cwd
210
+ */
211
+ export function ensureForgeReadme(cwd) {
212
+ const readme = path.join(cwd, '.forge', 'README.md');
213
+ if (fs.existsSync(readme)) return 'exists';
214
+ fs.writeFileSync(
215
+ readme,
216
+ `# \`.forge/\` — Forge session scratch
217
+
218
+ Per-checkout, **gitignored** workspace for Forge session orchestration.
219
+ Canonical specs live in \`openspec/\`; this directory holds session-local artefacts only.
220
+
221
+ \`\`\`bash
222
+ forge new <slug>
223
+ forge status
224
+ forge prefs
225
+ forge models
226
+ forge cleanup
227
+ \`\`\`
228
+
229
+ See the Forge skill and forgekit docs for the full workflow.
230
+ `,
231
+ 'utf8',
232
+ );
233
+ return 'written';
234
+ }
235
+
236
+ /**
237
+ * Append hook registrations into Claude settings.json if present / create stub note.
238
+ * @param {string} cwd
239
+ * @param {{ force?: boolean }} opts
240
+ */
241
+ export function ensureClaudeHookHints(cwd, opts) {
242
+ const settingsPath = path.join(cwd, '.claude', 'settings.json');
243
+ const notePath = path.join(cwd, '.claude', 'forge-hooks.snippet.json');
244
+ const snippet = {
245
+ _comment:
246
+ 'Merge these hooks into .claude/settings.json (SessionStart + UserPromptSubmit). Paths assume forge CLI is on PATH.',
247
+ hooks: {
248
+ SessionStart: [
249
+ {
250
+ hooks: [
251
+ {
252
+ type: 'command',
253
+ command: 'node "${CLAUDE_PROJECT_DIR}/.claude/hooks/forge-session-start.mjs"',
254
+ },
255
+ ],
256
+ },
257
+ ],
258
+ UserPromptSubmit: [
259
+ {
260
+ hooks: [
261
+ {
262
+ type: 'command',
263
+ command: 'node "${CLAUDE_PROJECT_DIR}/.claude/hooks/forge-triage-hook.mjs"',
264
+ },
265
+ {
266
+ type: 'command',
267
+ command: 'node "${CLAUDE_PROJECT_DIR}/.claude/hooks/forge-prompt-hook.mjs"',
268
+ },
269
+ ],
270
+ },
271
+ ],
272
+ },
273
+ };
274
+ fs.mkdirSync(path.dirname(notePath), { recursive: true });
275
+ if (!fs.existsSync(notePath) || opts.force) {
276
+ fs.writeFileSync(notePath, `${JSON.stringify(snippet, null, 2)}\n`, 'utf8');
277
+ }
278
+ return {
279
+ settingsExists: fs.existsSync(settingsPath),
280
+ snippet: notePath,
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Write Cursor hooks.json snippet.
286
+ * @param {string} cwd
287
+ * @param {{ force?: boolean }} opts
288
+ */
289
+ export function ensureCursorHookHints(cwd, opts) {
290
+ const notePath = path.join(cwd, '.cursor', 'forge-hooks.snippet.json');
291
+ const snippet = {
292
+ _comment:
293
+ 'Merge into .cursor/hooks.json. Requires Node on PATH (forge-session-start.mjs).',
294
+ version: 1,
295
+ hooks: {
296
+ sessionStart: [
297
+ {
298
+ command: 'node .cursor/hooks/forge-session-start.mjs',
299
+ },
300
+ ],
301
+ },
302
+ };
303
+ fs.mkdirSync(path.dirname(notePath), { recursive: true });
304
+ if (!fs.existsSync(notePath) || opts.force) {
305
+ fs.writeFileSync(notePath, `${JSON.stringify(snippet, null, 2)}\n`, 'utf8');
306
+ }
307
+ return notePath;
308
+ }
309
+
310
+ /**
311
+ * @param {string[]} selected
312
+ * @param {{ cwd: string, force?: boolean, overlay?: boolean, templatesRoot?: string, adr?: boolean | null, adrDir?: string | null, home?: string, planEngine?: string | null, planDir?: string | null }} opts
313
+ */
314
+ export function initProject(selected, opts) {
315
+ const templates = opts.templatesRoot ?? resolveTemplatesRoot();
316
+ const cwd = opts.cwd;
317
+ /** @type {Record<string, unknown>} */
318
+ const report = {
319
+ cwd,
320
+ gitignore: ensureForgeGitignore(cwd),
321
+ readme: ensureForgeReadme(cwd),
322
+ files: /** @type {{ file: string, status: string }[]} */ ([]),
323
+ };
324
+
325
+ const copyOpts = { force: opts.force, cwd };
326
+
327
+ if (selected.includes('cursor')) {
328
+ report.files.push(
329
+ ...copyDirFiles(
330
+ path.join(templates, 'cursor', 'commands'),
331
+ path.join(cwd, '.cursor', 'commands'),
332
+ copyOpts,
333
+ ),
334
+ ...copyDirFiles(
335
+ path.join(templates, 'cursor', 'rules'),
336
+ path.join(cwd, '.cursor', 'rules'),
337
+ copyOpts,
338
+ ),
339
+ ...copyDirFiles(
340
+ path.join(templates, 'cursor', 'hooks'),
341
+ path.join(cwd, '.cursor', 'hooks'),
342
+ copyOpts,
343
+ ),
344
+ );
345
+ report.cursorHookSnippet = ensureCursorHookHints(cwd, copyOpts);
346
+ }
347
+
348
+ if (selected.includes('claude')) {
349
+ report.files.push(
350
+ ...copyDirFiles(
351
+ path.join(templates, 'claude', 'commands'),
352
+ path.join(cwd, '.claude', 'commands'),
353
+ copyOpts,
354
+ ),
355
+ ...copyDirFiles(
356
+ path.join(templates, 'claude', 'rules'),
357
+ path.join(cwd, '.claude', 'rules'),
358
+ copyOpts,
359
+ ),
360
+ ...copyDirFiles(
361
+ path.join(templates, 'claude', 'hooks'),
362
+ path.join(cwd, '.claude', 'hooks'),
363
+ copyOpts,
364
+ ),
365
+ );
366
+ report.claudeHooks = ensureClaudeHookHints(cwd, copyOpts);
367
+ }
368
+
369
+ if (selected.includes('codex')) {
370
+ report.files.push(
371
+ ...copyDirFiles(
372
+ path.join(templates, 'codex', 'rules'),
373
+ path.join(cwd, '.codex', 'rules'),
374
+ copyOpts,
375
+ ),
376
+ );
377
+ }
378
+
379
+ // Selected environments without project-wiring templates: the globally
380
+ // installed skill is their interface — nothing to scaffold per project.
381
+ report.skillOnly = selected.filter((id) => !WIRED_AGENTS.includes(id));
382
+
383
+ if (opts.planEngine === 'specs') {
384
+ const scaffold = scaffoldSpecs(cwd, {
385
+ force: opts.force,
386
+ dir: opts.planDir ?? undefined,
387
+ });
388
+ const config = writeProjectPlanConfig(cwd, {
389
+ engine: 'specs',
390
+ dir: scaffold.dir,
391
+ });
392
+ report.plan = { engine: 'specs', dir: scaffold.dir, files: scaffold.files, config };
393
+ } else if (opts.planEngine === 'openspec') {
394
+ if (opts.planDir) {
395
+ process.stderr.write(
396
+ 'Note: --plan-dir applies to the built-in specs engine; ignoring for openspec.\n',
397
+ );
398
+ }
399
+ const config = writeProjectPlanConfig(cwd, { engine: 'openspec' });
400
+ report.plan = {
401
+ engine: 'openspec',
402
+ configured: hasOpenSpecConfig(cwd),
403
+ config,
404
+ };
405
+ }
406
+
407
+ if (opts.adr === true) {
408
+ const user = loadUserConfig(opts.home);
409
+ const dir = normalizeAdrDir(
410
+ opts.adrDir ?? user.adr?.dir ?? DEFAULT_ADR_DIR,
411
+ );
412
+ report.adr = scaffoldAdr(cwd, {
413
+ dir,
414
+ force: opts.force,
415
+ hooks: true,
416
+ });
417
+ } else if (opts.adr === false) {
418
+ report.adr = { config: disableProjectAdr(cwd), enabled: false };
419
+ }
420
+
421
+ if (opts.overlay) {
422
+ const overlayScript = path.join(__dirname, 'vendor-openspec-overlays.mjs');
423
+ const r = spawnSync(process.execPath, [overlayScript], {
424
+ cwd,
425
+ encoding: 'utf8',
426
+ });
427
+ report.overlay = {
428
+ status: r.status,
429
+ stdout: r.stdout?.trim() || '',
430
+ stderr: r.stderr?.trim() || '',
431
+ };
432
+ }
433
+
434
+ return report;
435
+ }
436
+
437
+ /**
438
+ * Environments this project already has Forge wiring for (marker dir present).
439
+ * @param {string} cwd
440
+ * @returns {Set<string>}
441
+ */
442
+ function wiredAgents(cwd) {
443
+ const markers = {
444
+ cursor: path.join(cwd, '.cursor', 'commands'),
445
+ claude: path.join(cwd, '.claude', 'commands'),
446
+ codex: path.join(cwd, '.codex', 'rules'),
447
+ };
448
+ return new Set(
449
+ Object.entries(markers)
450
+ .filter(([, dir]) => fs.existsSync(dir))
451
+ .map(([id]) => id),
452
+ );
453
+ }
454
+
455
+ /**
456
+ * Environments to pre-check: those chosen during `forgekit install`
457
+ * (saved in ~/.forgekit/config.json), plus what is already installed or wired.
458
+ * @param {string} cwd
459
+ * @param {string} [home]
460
+ * @returns {Set<string>}
461
+ */
462
+ export function rememberedAgents(cwd, home) {
463
+ const user = loadUserConfig(home);
464
+ return new Set([
465
+ ...(Array.isArray(user.agents) ? user.agents : []),
466
+ ...installedManagedPairs(home).map((p) => p.agent),
467
+ ...wiredAgents(cwd),
468
+ ]);
469
+ }
470
+
471
+ /** @param {string} cwd */
472
+ async function promptAgents(cwd) {
473
+ const remembered = rememberedAgents(cwd);
474
+ return checkbox({
475
+ message: 'Init Forge project wiring for which environments?',
476
+ choices: AGENT_IDS.map((id) => ({
477
+ value: id,
478
+ name: AGENTS[id].label,
479
+ checked: remembered.has(id),
480
+ })),
481
+ required: true,
482
+ });
483
+ }
484
+
485
+ /**
486
+ * Offer to install + set up OpenSpec in this project (engine already chosen).
487
+ * @returns {Promise<boolean>} true = user accepted OpenSpec setup now
488
+ */
489
+ async function promptOpenSpecSetup() {
490
+ return confirm({
491
+ message: 'OpenSpec is not set up in this project. Install and set it up now?',
492
+ default: true,
493
+ });
494
+ }
495
+
496
+ /**
497
+ * Resolve the planning engine for `forge init`, offering OpenSpec setup when needed.
498
+ *
499
+ * Choosing OpenSpec (flag, user default, or interactive pick) always records
500
+ * `plan.engine: openspec`. Immediate `openspec init` is best-effort — failure
501
+ * or declining setup must not fall back to the built-in specs engine.
502
+ *
503
+ * @param {{
504
+ * cwd: string,
505
+ * openspec: boolean | null,
506
+ * agents?: string[],
507
+ * home?: string,
508
+ * isTTY?: boolean,
509
+ * confirmSetup?: () => Promise<boolean>,
510
+ * promptEngine?: () => Promise<boolean>,
511
+ * setup?: typeof setupOpenSpec,
512
+ * loadUser?: (home?: string) => string | null,
513
+ * }} opts
514
+ * @returns {Promise<string>} 'openspec' | 'specs'
515
+ */
516
+ export async function resolveInitPlanEngine(opts) {
517
+ const configured = hasOpenSpecConfig(opts.cwd);
518
+ const tools = opts.agents;
519
+ const isTTY = opts.isTTY ?? Boolean(process.stdin.isTTY);
520
+ const runSetup = opts.setup ?? setupOpenSpec;
521
+ const loadUser = opts.loadUser ?? ((home) => loadUserPlanEngine(home));
522
+ const confirmSetup = opts.confirmSetup ?? promptOpenSpecSetup;
523
+ const promptEngine = opts.promptEngine ?? promptOpenSpec;
524
+
525
+ const reportSetup = (setup) => {
526
+ for (const s of setup.steps) {
527
+ process.stdout.write(
528
+ ` [${s.ok ? 'ok' : 'FAIL'}] ${s.step}${s.detail ? ` — ${s.detail}` : ''}\n`,
529
+ );
530
+ }
531
+ if (!setup.ok) {
532
+ process.stderr.write(
533
+ 'OpenSpec setup failed — engine recorded as openspec; re-run `forge doctor --install` or `openspec init` manually.\n',
534
+ );
535
+ }
536
+ };
537
+
538
+ /** Best-effort setup; never changes the chosen engine. */
539
+ const ensureOpenSpecSetup = async ({ ask }) => {
540
+ if (configured) return;
541
+ if (!isTTY) return;
542
+ if (ask) {
543
+ const accepted = await confirmSetup();
544
+ if (!accepted) {
545
+ process.stdout.write(
546
+ 'OpenSpec engine recorded; run `openspec init` (or `forge doctor --install`) when ready.\n',
547
+ );
548
+ return;
549
+ }
550
+ }
551
+ reportSetup(runSetup(opts.cwd, { tools }));
552
+ };
553
+
554
+ if (opts.openspec === false) return 'specs';
555
+
556
+ if (opts.openspec === true) {
557
+ // Flag means engine=openspec; attempt setup without a second prompt.
558
+ await ensureOpenSpecSetup({ ask: false });
559
+ return 'openspec';
560
+ }
561
+
562
+ if (configured) return 'openspec';
563
+
564
+ const userDefault = loadUser(opts.home);
565
+ if (userDefault === 'specs') return 'specs';
566
+
567
+ if (userDefault === 'openspec') {
568
+ await ensureOpenSpecSetup({ ask: true });
569
+ return 'openspec';
570
+ }
571
+
572
+ // No user default — ask on TTY, otherwise leave project on built-in specs.
573
+ if (!isTTY) return 'specs';
574
+
575
+ const wantOpenSpec = await promptEngine();
576
+ if (!wantOpenSpec) return 'specs';
577
+
578
+ await ensureOpenSpecSetup({ ask: true });
579
+ return 'openspec';
580
+ }
581
+
582
+ /**
583
+ * @param {string} [defaultDir]
584
+ * @param {boolean} [defaultEnabled]
585
+ * @returns {Promise<{ enabled: boolean, dir: string }>}
586
+ */
587
+ async function promptAdrForInit(defaultDir = DEFAULT_ADR_DIR, defaultEnabled = true) {
588
+ const enabled = await confirm({
589
+ message: 'Use Architecture Decision Records (ADRs) in this project?',
590
+ default: defaultEnabled,
591
+ });
592
+ if (!enabled) return { enabled: false, dir: defaultDir };
593
+ const dir = await input({
594
+ message: 'ADR directory inside the repo',
595
+ default: defaultDir,
596
+ });
597
+ return { enabled: true, dir: normalizeAdrDir(dir.trim() || defaultDir) };
598
+ }
599
+
600
+ async function main(argv = process.argv.slice(2)) {
601
+ const opts = parseArgs(argv);
602
+ if (opts.help) {
603
+ printHelp();
604
+ return 0;
605
+ }
606
+
607
+ let selected = opts.all ? [...AGENT_IDS] : [...new Set(opts.agents)];
608
+ if (selected.length === 0) {
609
+ if (!process.stdin.isTTY) {
610
+ process.stderr.write(
611
+ 'No agents specified. Pass --cursor/--claude/--codex/--copilot/--gemini/--windsurf/--opencode/--all, or run in a TTY.\n',
612
+ );
613
+ return 1;
614
+ }
615
+ selected = await promptAgents(opts.cwd);
616
+ }
617
+
618
+ for (const id of selected) {
619
+ if (!AGENTS[id]) {
620
+ process.stderr.write(`Unknown environment: ${id}. Known: ${AGENT_IDS.join(', ')}\n`);
621
+ return 1;
622
+ }
623
+ }
624
+
625
+ const planEngine = await resolveInitPlanEngine({
626
+ cwd: opts.cwd,
627
+ openspec: opts.openspec,
628
+ agents: selected,
629
+ });
630
+
631
+ let adr = opts.adr;
632
+ let adrDir = opts.adrDir;
633
+ if (adr === null) {
634
+ const user = loadUserConfig();
635
+ const defaultDir = user.adr?.dir ?? DEFAULT_ADR_DIR;
636
+ if (process.stdin.isTTY) {
637
+ // Default Yes, unless the user globally opted out of ADRs.
638
+ const picked = await promptAdrForInit(defaultDir, user.adr?.enabled !== false);
639
+ adr = picked.enabled;
640
+ adrDir = picked.dir;
641
+ } else if (user.adr?.enabled === true) {
642
+ adr = true;
643
+ adrDir = user.adr.dir ?? DEFAULT_ADR_DIR;
644
+ } else {
645
+ adr = false;
646
+ }
647
+ }
648
+
649
+ const report = initProject(selected, {
650
+ ...opts,
651
+ adr,
652
+ adrDir,
653
+ planEngine,
654
+ planDir: opts.planDir,
655
+ });
656
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
657
+ if (Array.isArray(report.skillOnly) && report.skillOnly.length) {
658
+ const labels = report.skillOnly.map((id) => AGENTS[id].label).join(', ');
659
+ process.stdout.write(
660
+ `\nNo project wiring for: ${labels} — they use the globally installed Forge skill directly (run \`forgekit install\` if not yet installed).\n`,
661
+ );
662
+ }
663
+ process.stdout.write(
664
+ `\nMerge hook snippets into settings if needed, ensure \`forge\` is on PATH, then open the project in your agent.\n`,
665
+ );
666
+ return 0;
667
+ }
668
+
669
+ const isDirect =
670
+ process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
671
+
672
+ if (isDirect) {
673
+ main()
674
+ .then((code) => process.exit(code))
675
+ .catch((err) => {
676
+ if (err?.name === 'ExitPromptError') process.exit(130);
677
+ process.stderr.write(`${err.message || err}\n`);
678
+ process.exit(1);
679
+ });
680
+ }