@izkac/forgekit 0.1.4 → 0.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izkac/forgekit",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Forgekit CLIs — forgekit (install), forge (workflow), review (code review)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/init.mjs CHANGED
@@ -30,6 +30,11 @@ import {
30
30
  writeProjectPlanConfig,
31
31
  } from './plan-engine.mjs';
32
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']);
33
38
 
34
39
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
35
40
 
@@ -61,6 +66,10 @@ export function parseArgs(argv) {
61
66
  else if (arg === '--cursor') opts.agents.push('cursor');
62
67
  else if (arg === '--claude' || arg === '--claude-code') opts.agents.push('claude');
63
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');
64
73
  else if (arg === '--adr') opts.adr = true;
65
74
  else if (arg === '--no-adr') opts.adr = false;
66
75
  else if (arg === '--adr-dir') opts.adrDir = argv[++i];
@@ -81,7 +90,10 @@ Options:
81
90
  --cursor Cursor (.cursor/commands, rules, hooks)
82
91
  --claude Claude Code (.claude/commands, rules, hooks)
83
92
  --codex Codex CLI (.codex/rules)
84
- --all All of the above
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
85
97
  --openspec Plan with OpenSpec (offer install + \`openspec init\` if missing)
86
98
  --no-openspec Plan with the built-in specs engine (${DEFAULT_SPECS_DIR}/changes/)
87
99
  --adr Enable ADRs (scaffold decisions.md + ADR dir + hooks)
@@ -95,10 +107,12 @@ Options:
95
107
  Requires the Forge skill already installed (\`forge install\`) for agents
96
108
  to load skill content. Init only adds project-local wiring.
97
109
 
98
- Interactive (TTY): when --openspec/--no-openspec omitted and OpenSpec is not
99
- already set up, offers to install + set it up (decline = built-in specs
100
- engine). When --adr/--no-adr omitted, asks whether to use ADRs and for the
101
- directory inside the repo.
110
+ Interactive (TTY): the environment picker matches \`forgekit install\` and is
111
+ pre-checked with what you installed there (saved in ~/.forgekit/config.json),
112
+ so you don't pick twice. When --openspec/--no-openspec omitted and OpenSpec is
113
+ not already set up, offers to install + set it up (decline = built-in specs
114
+ engine). When --adr/--no-adr omitted, asks whether to use ADRs (default Yes)
115
+ and for the directory inside the repo.
102
116
  `);
103
117
  }
104
118
 
@@ -343,6 +357,10 @@ export function initProject(selected, opts) {
343
357
  );
344
358
  }
345
359
 
360
+ // Selected environments without project-wiring templates: the globally
361
+ // installed skill is their interface — nothing to scaffold per project.
362
+ report.skillOnly = selected.filter((id) => !WIRED_AGENTS.includes(id));
363
+
346
364
  if (opts.planEngine === 'specs') {
347
365
  const scaffold = scaffoldSpecs(cwd, { force: opts.force });
348
366
  const config = writeProjectPlanConfig(cwd, {
@@ -407,16 +425,32 @@ function wiredAgents(cwd) {
407
425
  );
408
426
  }
409
427
 
428
+ /**
429
+ * Environments to pre-check: those chosen during `forgekit install`
430
+ * (saved in ~/.forgekit/config.json), plus what is already installed or wired.
431
+ * @param {string} cwd
432
+ * @param {string} [home]
433
+ * @returns {Set<string>}
434
+ */
435
+ export function rememberedAgents(cwd, home) {
436
+ const user = loadUserConfig(home);
437
+ return new Set([
438
+ ...(Array.isArray(user.agents) ? user.agents : []),
439
+ ...installedManagedPairs(home).map((p) => p.agent),
440
+ ...wiredAgents(cwd),
441
+ ]);
442
+ }
443
+
410
444
  /** @param {string} cwd */
411
445
  async function promptAgents(cwd) {
412
- const wired = wiredAgents(cwd);
446
+ const remembered = rememberedAgents(cwd);
413
447
  return checkbox({
414
448
  message: 'Init Forge project wiring for which environments?',
415
- choices: [
416
- { value: 'cursor', name: 'Cursor', checked: wired.has('cursor') },
417
- { value: 'claude', name: 'Claude Code', checked: wired.has('claude') },
418
- { value: 'codex', name: 'Codex CLI', checked: wired.has('codex') },
419
- ],
449
+ choices: AGENT_IDS.map((id) => ({
450
+ value: id,
451
+ name: AGENTS[id].label,
452
+ checked: remembered.has(id),
453
+ })),
420
454
  required: true,
421
455
  });
422
456
  }
@@ -435,17 +469,18 @@ async function promptOpenSpecSetup() {
435
469
 
436
470
  /**
437
471
  * Resolve the planning engine for `forge init`, offering OpenSpec setup when needed.
438
- * @param {{ cwd: string, openspec: boolean | null }} opts
472
+ * @param {{ cwd: string, openspec: boolean | null, agents?: string[] }} opts
439
473
  * @returns {Promise<string>} 'openspec' | 'specs'
440
474
  */
441
475
  async function resolveInitPlanEngine(opts) {
442
476
  const configured = hasOpenSpecConfig(opts.cwd);
477
+ const tools = opts.agents;
443
478
 
444
479
  if (opts.openspec === false) return 'specs';
445
480
 
446
481
  if (opts.openspec === true) {
447
482
  if (!configured && process.stdin.isTTY) {
448
- const setup = setupOpenSpec(opts.cwd);
483
+ const setup = setupOpenSpec(opts.cwd, { tools });
449
484
  for (const s of setup.steps) {
450
485
  process.stdout.write(` [${s.ok ? 'ok' : 'FAIL'}] ${s.step}${s.detail ? ` — ${s.detail}` : ''}\n`);
451
486
  }
@@ -471,7 +506,7 @@ async function resolveInitPlanEngine(opts) {
471
506
  const accepted = await promptOpenSpecSetup();
472
507
  if (!accepted) return 'specs';
473
508
 
474
- const setup = setupOpenSpec(opts.cwd);
509
+ const setup = setupOpenSpec(opts.cwd, { tools });
475
510
  for (const s of setup.steps) {
476
511
  process.stdout.write(` [${s.ok ? 'ok' : 'FAIL'}] ${s.step}${s.detail ? ` — ${s.detail}` : ''}\n`);
477
512
  }
@@ -486,12 +521,13 @@ async function resolveInitPlanEngine(opts) {
486
521
 
487
522
  /**
488
523
  * @param {string} [defaultDir]
524
+ * @param {boolean} [defaultEnabled]
489
525
  * @returns {Promise<{ enabled: boolean, dir: string }>}
490
526
  */
491
- async function promptAdrForInit(defaultDir = DEFAULT_ADR_DIR) {
527
+ async function promptAdrForInit(defaultDir = DEFAULT_ADR_DIR, defaultEnabled = true) {
492
528
  const enabled = await confirm({
493
529
  message: 'Use Architecture Decision Records (ADRs) in this project?',
494
- default: false,
530
+ default: defaultEnabled,
495
531
  });
496
532
  if (!enabled) return { enabled: false, dir: defaultDir };
497
533
  const dir = await input({
@@ -508,20 +544,28 @@ async function main(argv = process.argv.slice(2)) {
508
544
  return 0;
509
545
  }
510
546
 
511
- let selected = opts.all ? ['cursor', 'claude', 'codex'] : [...new Set(opts.agents)];
547
+ let selected = opts.all ? [...AGENT_IDS] : [...new Set(opts.agents)];
512
548
  if (selected.length === 0) {
513
549
  if (!process.stdin.isTTY) {
514
550
  process.stderr.write(
515
- 'No agents specified. Pass --cursor/--claude/--codex/--all, or run in a TTY.\n',
551
+ 'No agents specified. Pass --cursor/--claude/--codex/--copilot/--gemini/--windsurf/--opencode/--all, or run in a TTY.\n',
516
552
  );
517
553
  return 1;
518
554
  }
519
555
  selected = await promptAgents(opts.cwd);
520
556
  }
521
557
 
558
+ for (const id of selected) {
559
+ if (!AGENTS[id]) {
560
+ process.stderr.write(`Unknown environment: ${id}. Known: ${AGENT_IDS.join(', ')}\n`);
561
+ return 1;
562
+ }
563
+ }
564
+
522
565
  const planEngine = await resolveInitPlanEngine({
523
566
  cwd: opts.cwd,
524
567
  openspec: opts.openspec,
568
+ agents: selected,
525
569
  });
526
570
 
527
571
  let adr = opts.adr;
@@ -530,7 +574,8 @@ async function main(argv = process.argv.slice(2)) {
530
574
  const user = loadUserConfig();
531
575
  const defaultDir = user.adr?.dir ?? DEFAULT_ADR_DIR;
532
576
  if (process.stdin.isTTY) {
533
- const picked = await promptAdrForInit(defaultDir);
577
+ // Default Yes, unless the user globally opted out of ADRs.
578
+ const picked = await promptAdrForInit(defaultDir, user.adr?.enabled !== false);
534
579
  adr = picked.enabled;
535
580
  adrDir = picked.dir;
536
581
  } else if (user.adr?.enabled === true) {
@@ -543,6 +588,12 @@ async function main(argv = process.argv.slice(2)) {
543
588
 
544
589
  const report = initProject(selected, { ...opts, adr, adrDir, planEngine });
545
590
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
591
+ if (Array.isArray(report.skillOnly) && report.skillOnly.length) {
592
+ const labels = report.skillOnly.map((id) => AGENTS[id].label).join(', ');
593
+ process.stdout.write(
594
+ `\nNo project wiring for: ${labels} — they use the globally installed Forge skill directly (run \`forgekit install\` if not yet installed).\n`,
595
+ );
596
+ }
546
597
  process.stdout.write(
547
598
  `\nMerge hook snippets into settings if needed, ensure \`forge\` is on PATH, then open the project in your agent.\n`,
548
599
  );
@@ -0,0 +1,53 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { parseArgs, initProject, rememberedAgents } from './init.mjs';
7
+ import { installSkillsToAgents } from './install.mjs';
8
+ import { saveUserConfig } from './config.mjs';
9
+
10
+ test('init parseArgs accepts the expanded environment shorthands', () => {
11
+ const opts = parseArgs(['--cursor', '--copilot', '--gemini', '--windsurf', '--opencode']);
12
+ assert.deepEqual(opts.agents, ['cursor', 'copilot', 'gemini', 'windsurf', 'opencode']);
13
+ });
14
+
15
+ test('rememberedAgents unions install config, installed skills, and project wiring', () => {
16
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-home-'));
17
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-proj-'));
18
+ try {
19
+ // Chosen during `forgekit install` (saved to user config).
20
+ saveUserConfig({ agents: ['claude', 'gemini'] }, home);
21
+ // Actually installed skill for another env.
22
+ installSkillsToAgents(['forge'], ['copilot'], { home, force: true });
23
+ // Project already wired for cursor.
24
+ fs.mkdirSync(path.join(cwd, '.cursor', 'commands'), { recursive: true });
25
+
26
+ const remembered = rememberedAgents(cwd, home);
27
+ assert.ok(remembered.has('claude'), 'from install config');
28
+ assert.ok(remembered.has('gemini'), 'from install config');
29
+ assert.ok(remembered.has('copilot'), 'from installed skill dir');
30
+ assert.ok(remembered.has('cursor'), 'from project wiring marker');
31
+ assert.ok(!remembered.has('codex'));
32
+ } finally {
33
+ fs.rmSync(home, { recursive: true, force: true });
34
+ fs.rmSync(cwd, { recursive: true, force: true });
35
+ }
36
+ });
37
+
38
+ test('initProject wires templated envs and marks the rest skill-only', () => {
39
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-init-'));
40
+ try {
41
+ const report = initProject(['cursor', 'copilot', 'gemini'], {
42
+ cwd,
43
+ force: true,
44
+ adr: false,
45
+ planEngine: null,
46
+ });
47
+ assert.ok(report.files.some((f) => f.file.includes('.cursor')));
48
+ assert.deepEqual(report.skillOnly, ['copilot', 'gemini']);
49
+ assert.ok(!fs.existsSync(path.join(cwd, '.copilot')));
50
+ } finally {
51
+ fs.rmSync(cwd, { recursive: true, force: true });
52
+ }
53
+ });
package/src/install.mjs CHANGED
@@ -730,6 +730,9 @@ export async function runInstall(argv = process.argv.slice(2)) {
730
730
 
731
731
  saveUserConfig({
732
732
  adr: { enabled: adrOpts.enabled, dir: adrOpts.dir },
733
+ // Remember the environment set so `forge init` can pre-check it. Only when
734
+ // deliberately chosen (picker or --all-agents) — narrow flag runs don't clobber it.
735
+ ...(resolved.agentsPrompted || opts.allAgents || opts.all ? { agents } : {}),
733
736
  });
734
737
 
735
738
  const { results, removed } = prune
@@ -204,12 +204,28 @@ export function checkOpenSpecCliQuick(runCommand) {
204
204
  return { ok: false, version: null };
205
205
  }
206
206
 
207
+ /**
208
+ * Map forgekit environment ids → OpenSpec tool ids (mostly identity).
209
+ * @type {Record<string, string>}
210
+ */
211
+ const OPENSPEC_TOOL_ID = { copilot: 'github-copilot' };
212
+
213
+ /**
214
+ * @param {string[]} agentIds forgekit environment ids
215
+ * @returns {string[]} OpenSpec tool ids
216
+ */
217
+ export function toOpenSpecToolIds(agentIds) {
218
+ return agentIds.map((id) => OPENSPEC_TOOL_ID[id] ?? id);
219
+ }
220
+
207
221
  /**
208
222
  * Install the OpenSpec CLI (if missing) and run `openspec init` in the project.
209
- * Interactive: inherits stdio so `openspec init` can prompt.
223
+ * Interactive: inherits stdio so `openspec init` can prompt — unless `tools`
224
+ * is given, in which case those tools are configured non-interactively
225
+ * (`openspec init --tools <ids>`), skipping OpenSpec's own tool picker.
210
226
  *
211
227
  * @param {string} cwd
212
- * @param {{ runCommand?: Function, interactive?: boolean }} [opts]
228
+ * @param {{ runCommand?: Function, interactive?: boolean, tools?: string[] }} [opts]
213
229
  * @returns {{ ok: boolean, steps: { step: string, ok: boolean, detail?: string }[] }}
214
230
  */
215
231
  export function setupOpenSpec(cwd, opts = {}) {
@@ -249,10 +265,12 @@ export function setupOpenSpec(cwd, opts = {}) {
249
265
  return { ok: true, steps };
250
266
  }
251
267
 
252
- const init = runInherit('openspec', ['init']);
268
+ const tools = opts.tools?.length ? toOpenSpecToolIds(opts.tools) : null;
269
+ const initArgs = tools ? ['init', '--tools', tools.join(',')] : ['init'];
270
+ const init = runInherit('openspec', initArgs);
253
271
  const initOk = init.status === 0;
254
272
  steps.push({
255
- step: 'openspec init',
273
+ step: `openspec ${initArgs.join(' ')}`,
256
274
  ok: initOk,
257
275
  detail: initOk ? undefined : String(init.error ?? `exit ${init.status}`),
258
276
  });
@@ -12,6 +12,7 @@ import {
12
12
  saveUserPlanEngine,
13
13
  scaffoldSpecs,
14
14
  setupOpenSpec,
15
+ toOpenSpecToolIds,
15
16
  writeProjectPlanConfig,
16
17
  } from './plan-engine.mjs';
17
18
  import { loadProjectConfig, loadUserConfig, saveUserConfig } from './adr.mjs';
@@ -148,6 +149,43 @@ test('setupOpenSpec runs install + init via injected runner', () => {
148
149
  }
149
150
  });
150
151
 
152
+ test('toOpenSpecToolIds maps copilot → github-copilot, else identity', () => {
153
+ assert.deepEqual(
154
+ toOpenSpecToolIds(['claude', 'cursor', 'codex', 'opencode', 'copilot', 'gemini', 'windsurf']),
155
+ ['claude', 'cursor', 'codex', 'opencode', 'github-copilot', 'gemini', 'windsurf'],
156
+ );
157
+ });
158
+
159
+ test('setupOpenSpec passes selected tools to `openspec init --tools`', () => {
160
+ const cwd = tmpdir('forgekit-openspec-tools-');
161
+ try {
162
+ /** @type {string[]} */
163
+ const calls = [];
164
+ const runCommand = (cmd, args) => {
165
+ const line = [cmd, ...args].join(' ');
166
+ calls.push(line);
167
+ if (line === 'openspec --version') return { status: 0, stdout: '1.0.0' };
168
+ if (line.startsWith('openspec init')) {
169
+ fs.mkdirSync(path.join(cwd, 'openspec'), { recursive: true });
170
+ fs.writeFileSync(path.join(cwd, 'openspec', 'config.yaml'), 'x: 1\n', 'utf8');
171
+ return { status: 0, stdout: '' };
172
+ }
173
+ return { status: 1, stdout: '' };
174
+ };
175
+ const result = setupOpenSpec(cwd, {
176
+ runCommand,
177
+ tools: ['claude', 'cursor', 'codex', 'copilot'],
178
+ });
179
+ assert.equal(result.ok, true);
180
+ assert.ok(
181
+ calls.includes('openspec init --tools claude,cursor,codex,github-copilot'),
182
+ `expected --tools call, got: ${calls.join(' | ')}`,
183
+ );
184
+ } finally {
185
+ fs.rmSync(cwd, { recursive: true, force: true });
186
+ }
187
+ });
188
+
151
189
  test('setupOpenSpec reports failure when install fails', () => {
152
190
  const cwd = tmpdir('forgekit-openspec-fail-');
153
191
  try {