@izkac/forgekit 0.1.3 → 0.1.4

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
@@ -15,17 +15,19 @@ npm i -g @izkac/forgekit
15
15
  forgekit install
16
16
  ```
17
17
 
18
- Interactive install lets you pick **one or more** skills (comma-separated, e.g. `1,3`) or all. Same for agent environments.
18
+ Interactive install uses arrow-key checkboxes space to toggle, **`a` to select all**, enter to confirm. Pickers come pre-checked with what you already have installed; choosing the full set reconciles (new picks install, deselected ones are removed).
19
+
20
+ Supported environments: Claude Code, Cursor, Codex CLI, GitHub Copilot, Gemini CLI, Windsurf, opencode — each into its global Agent-Skills directory.
19
21
 
20
22
  Non-interactive:
21
23
 
22
24
  ```bash
23
- forgekit install --skills forge,thorough-code-review --agents cursor,claude --force
25
+ forgekit install --skills forge,thorough-code-review --agents cursor,claude,copilot --force
24
26
  ```
25
27
 
26
28
  ## Docs
27
29
 
28
- - After install, full Forge reference: `~/.cursor/skills/forge/docs/forge.md` (or `~/.claude/…` / `~/.codex/…`)
30
+ - After install, full Forge reference: `~/.claude/skills/forge/docs/forge.md` (or the matching dir for your environment)
29
31
  - [How to use Forgekit](https://github.com/izkac/forgekit/blob/main/docs/usage.md)
30
32
  - [Repository README](https://github.com/izkac/forgekit#readme)
31
33
 
package/package.json CHANGED
@@ -1,49 +1,49 @@
1
- {
2
- "name": "@izkac/forgekit",
3
- "version": "0.1.3",
4
- "description": "Forgekit CLIs — forgekit (install), forge (workflow), review (code review)",
5
- "type": "module",
6
- "bin": {
7
- "forgekit": "./bin/forgekit.mjs",
8
- "forge": "./bin/forge.mjs",
9
- "review": "./bin/review.mjs"
10
- },
11
- "files": [
12
- "bin",
13
- "src",
14
- "vendor",
15
- "scripts"
16
- ],
17
- "scripts": {
18
- "prepack": "node scripts/prepack.mjs",
19
- "test": "node scripts/run-tests.mjs",
20
- "lint": "eslint src bin scripts"
21
- },
22
- "publishConfig": {
23
- "access": "public"
24
- },
25
- "engines": {
26
- "node": ">=20"
27
- },
28
- "repository": {
29
- "type": "git",
30
- "url": "git+https://github.com/izkac/forgekit.git",
31
- "directory": "packages/cli"
32
- },
33
- "homepage": "https://github.com/izkac/forgekit#readme",
34
- "bugs": {
35
- "url": "https://github.com/izkac/forgekit/issues"
36
- },
37
- "keywords": [
38
- "forge",
39
- "openspec",
40
- "agent-skills",
41
- "cursor",
42
- "claude",
43
- "codex"
44
- ],
45
- "license": "MIT",
46
- "dependencies": {
47
- "@inquirer/prompts": "^8.5.2"
48
- }
49
- }
1
+ {
2
+ "name": "@izkac/forgekit",
3
+ "version": "0.1.4",
4
+ "description": "Forgekit CLIs — forgekit (install), forge (workflow), review (code review)",
5
+ "type": "module",
6
+ "bin": {
7
+ "forgekit": "./bin/forgekit.mjs",
8
+ "forge": "./bin/forge.mjs",
9
+ "review": "./bin/review.mjs"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "src",
14
+ "vendor",
15
+ "scripts"
16
+ ],
17
+ "scripts": {
18
+ "prepack": "node scripts/prepack.mjs",
19
+ "test": "node scripts/run-tests.mjs",
20
+ "lint": "eslint src bin scripts"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/izkac/forgekit.git",
31
+ "directory": "packages/cli"
32
+ },
33
+ "homepage": "https://github.com/izkac/forgekit#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/izkac/forgekit/issues"
36
+ },
37
+ "keywords": [
38
+ "forge",
39
+ "openspec",
40
+ "agent-skills",
41
+ "cursor",
42
+ "claude",
43
+ "codex"
44
+ ],
45
+ "license": "MIT",
46
+ "dependencies": {
47
+ "@inquirer/prompts": "^8.5.2"
48
+ }
49
+ }
package/src/init.mjs CHANGED
@@ -389,13 +389,33 @@ export function initProject(selected, opts) {
389
389
  return report;
390
390
  }
391
391
 
392
- async function promptAgents() {
392
+ /**
393
+ * Environments this project already has Forge wiring for (marker dir present).
394
+ * @param {string} cwd
395
+ * @returns {Set<string>}
396
+ */
397
+ function wiredAgents(cwd) {
398
+ const markers = {
399
+ cursor: path.join(cwd, '.cursor', 'commands'),
400
+ claude: path.join(cwd, '.claude', 'commands'),
401
+ codex: path.join(cwd, '.codex', 'rules'),
402
+ };
403
+ return new Set(
404
+ Object.entries(markers)
405
+ .filter(([, dir]) => fs.existsSync(dir))
406
+ .map(([id]) => id),
407
+ );
408
+ }
409
+
410
+ /** @param {string} cwd */
411
+ async function promptAgents(cwd) {
412
+ const wired = wiredAgents(cwd);
393
413
  return checkbox({
394
414
  message: 'Init Forge project wiring for which environments?',
395
415
  choices: [
396
- { value: 'cursor', name: 'Cursor' },
397
- { value: 'claude', name: 'Claude Code' },
398
- { value: 'codex', name: 'Codex CLI' },
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') },
399
419
  ],
400
420
  required: true,
401
421
  });
@@ -496,7 +516,7 @@ async function main(argv = process.argv.slice(2)) {
496
516
  );
497
517
  return 1;
498
518
  }
499
- selected = await promptAgents();
519
+ selected = await promptAgents(opts.cwd);
500
520
  }
501
521
 
502
522
  const planEngine = await resolveInitPlanEngine({
package/src/install.mjs CHANGED
@@ -17,7 +17,7 @@ import fs from 'node:fs';
17
17
  import os from 'node:os';
18
18
  import path from 'node:path';
19
19
  import { pathToFileURL } from 'node:url';
20
- import { checkbox, confirm, input, select } from '@inquirer/prompts';
20
+ import { checkbox, input, select } from '@inquirer/prompts';
21
21
  import {
22
22
  ADR_SKILLS,
23
23
  DEFAULT_ADR_DIR,
@@ -58,20 +58,42 @@ export const SKILLS = {
58
58
 
59
59
  export const SKILL_IDS = Object.freeze(Object.keys(SKILLS));
60
60
 
61
- /** @type {Record<string, { label: string, skillDir: (home: string, skillId: string) => string }>} */
61
+ /**
62
+ * Supported environments and their user-level skills directory.
63
+ * Paths follow each tool's global Agent-Skills (SKILL.md) convention.
64
+ * @type {Record<string, { label: string, skillDir: (home: string, skillId: string) => string }>}
65
+ */
62
66
  export const AGENTS = {
63
- cursor: {
64
- label: 'Cursor',
65
- skillDir: (home, skillId) => path.join(home, '.cursor', 'skills', skillId),
66
- },
67
67
  claude: {
68
68
  label: 'Claude Code',
69
69
  skillDir: (home, skillId) => path.join(home, '.claude', 'skills', skillId),
70
70
  },
71
+ cursor: {
72
+ label: 'Cursor',
73
+ skillDir: (home, skillId) => path.join(home, '.cursor', 'skills', skillId),
74
+ },
71
75
  codex: {
72
76
  label: 'Codex CLI',
73
77
  skillDir: (home, skillId) => path.join(home, '.codex', 'skills', skillId),
74
78
  },
79
+ copilot: {
80
+ label: 'GitHub Copilot',
81
+ skillDir: (home, skillId) => path.join(home, '.copilot', 'skills', skillId),
82
+ },
83
+ gemini: {
84
+ label: 'Gemini CLI',
85
+ skillDir: (home, skillId) => path.join(home, '.gemini', 'skills', skillId),
86
+ },
87
+ windsurf: {
88
+ label: 'Windsurf',
89
+ skillDir: (home, skillId) =>
90
+ path.join(home, '.codeium', 'windsurf', 'skills', skillId),
91
+ },
92
+ opencode: {
93
+ label: 'opencode',
94
+ skillDir: (home, skillId) =>
95
+ path.join(home, '.config', 'opencode', 'skills', skillId),
96
+ },
75
97
  };
76
98
 
77
99
  export const AGENT_IDS = Object.freeze(Object.keys(AGENTS));
@@ -101,6 +123,7 @@ export function parseArgs(argv) {
101
123
  list: false,
102
124
  help: false,
103
125
  force: false,
126
+ prune: false,
104
127
  update: false,
105
128
  uninstall: false,
106
129
  /** @type {boolean | null} null = unset (prompt / infer) */
@@ -127,10 +150,15 @@ export function parseArgs(argv) {
127
150
  else if (arg === '--update') opts.update = true;
128
151
  else if (arg === '--uninstall') opts.uninstall = true;
129
152
  else if (arg === '--force' || arg === '-f') opts.force = true;
153
+ else if (arg === '--prune') opts.prune = true;
130
154
  else if (arg === '--help' || arg === '-h') opts.help = true;
131
155
  else if (arg === '--cursor') opts.agents.push('cursor');
132
156
  else if (arg === '--claude' || arg === '--claude-code') opts.agents.push('claude');
133
157
  else if (arg === '--codex') opts.agents.push('codex');
158
+ else if (arg === '--copilot') opts.agents.push('copilot');
159
+ else if (arg === '--gemini') opts.agents.push('gemini');
160
+ else if (arg === '--windsurf') opts.agents.push('windsurf');
161
+ else if (arg === '--opencode') opts.agents.push('opencode');
134
162
  else if (arg === '--adr') opts.adr = true;
135
163
  else if (arg === '--no-adr') opts.adr = false;
136
164
  else if (arg === '--adr-dir') opts.adrDir = argv[++i];
@@ -164,8 +192,10 @@ Options:
164
192
  --agents <ids> Comma list: ${AGENT_IDS.join(', ')}
165
193
  --all-skills Install every known skill
166
194
  --all-agents Install for every agent environment
167
- --cursor/--claude/--codex
195
+ --cursor/--claude/--codex/--copilot/--gemini/--windsurf/--opencode
168
196
  Shorthand agent flags (same as --agents)
197
+ --prune Reconcile: also remove installed skill×env pairs
198
+ outside the selection (implied by the full picker)
169
199
  --openspec Prefer OpenSpec as the planning engine (save user default)
170
200
  --no-openspec Prefer the built-in specs engine (save user default)
171
201
  --adr Enable ADRs (install ADR skills; save user default)
@@ -180,9 +210,12 @@ Options:
180
210
  --force, -f Overwrite existing skill directories
181
211
  --help
182
212
 
183
- Interactive (TTY) when skills and/or agents are omitted. You are also asked
184
- whether to plan with OpenSpec (vs the built-in specs engine), whether to use
185
- ADRs, and for the ADR path inside the repo (default ${DEFAULT_ADR_DIR}).
213
+ Interactive (TTY) when skills and/or agents are omitted: arrow-key pickers
214
+ (space to toggle, <a> for all) pre-checked with what you already have
215
+ installed. Choosing the full set reconciles newly picked pairs install,
216
+ deselected ones are removed. You are also asked whether to plan with OpenSpec
217
+ (vs the built-in specs engine). ADRs are enabled by picking an ADR skill; the
218
+ ADR path (default ${DEFAULT_ADR_DIR}) is only asked then.
186
219
 
187
220
  Aliases:
188
221
  forge install […] → forgekit install --skills forge […]
@@ -355,6 +388,54 @@ export function uninstallSkillsFromAgents(skillIds, agentIds, opts = {}) {
355
388
  return results;
356
389
  }
357
390
 
391
+ /**
392
+ * Every forgekit-managed skill×agent install currently on disk (has our stamp).
393
+ * This is the "memory" of what was installed — no separate state file needed.
394
+ * @param {string} [home]
395
+ * @returns {{ skill: string, agent: string, dest: string }[]}
396
+ */
397
+ export function installedManagedPairs(home = os.homedir()) {
398
+ /** @type {{ skill: string, agent: string, dest: string }[]} */
399
+ const pairs = [];
400
+ for (const skill of SKILL_IDS) {
401
+ for (const agent of AGENT_IDS) {
402
+ const dest = AGENTS[agent].skillDir(home, skill);
403
+ if (fs.existsSync(dest) && readInstallStamp(dest)) {
404
+ pairs.push({ skill, agent, dest });
405
+ }
406
+ }
407
+ }
408
+ return pairs;
409
+ }
410
+
411
+ /**
412
+ * Install the selected skills×agents and, when pruning, remove any managed
413
+ * install that falls outside the new selection.
414
+ * @param {string[]} skillIds
415
+ * @param {string[]} agentIds
416
+ * @param {{ home?: string, force?: boolean, prune?: boolean }} [opts]
417
+ */
418
+ export function reconcileInstall(skillIds, agentIds, opts = {}) {
419
+ const home = opts.home ?? os.homedir();
420
+ const desired = new Set();
421
+ for (const s of skillIds) for (const a of agentIds) desired.add(`${s}::${a}`);
422
+ /** @type {{ skill: string, agent: string, dest: string, status: string }[]} */
423
+ const removed = [];
424
+ if (opts.prune) {
425
+ for (const p of installedManagedPairs(home)) {
426
+ if (!desired.has(`${p.skill}::${p.agent}`)) {
427
+ removeDirRecursive(p.dest);
428
+ removed.push({ ...p, status: 'removed' });
429
+ }
430
+ }
431
+ }
432
+ const results = installSkillsToAgents(skillIds, agentIds, {
433
+ home,
434
+ force: opts.force ?? true,
435
+ });
436
+ return { results, removed };
437
+ }
438
+
358
439
  /**
359
440
  * Reinstall skills that are outdated or unversioned for agents that already have them.
360
441
  * @param {{ home?: string, skills?: string[], agents?: string[] }} [opts]
@@ -415,25 +496,32 @@ export function listInstallStatus(opts = {}) {
415
496
  /**
416
497
  * @param {string} message
417
498
  * @param {string[]} ids
499
+ * @param {string[]} [checkedIds] pre-selected (remembered from prior install)
418
500
  * @returns {Promise<string[]>}
419
501
  */
420
- async function promptMulti(message, ids) {
502
+ async function promptMulti(message, ids, checkedIds = []) {
503
+ const checked = new Set(checkedIds);
421
504
  return checkbox({
422
505
  message,
423
506
  choices: ids.map((id) => ({
424
507
  value: id,
425
508
  name: SKILLS[id]?.label ?? AGENTS[id]?.label ?? id,
509
+ checked: checked.has(id),
426
510
  })),
427
511
  required: true,
428
512
  });
429
513
  }
430
514
 
431
- async function promptSkills() {
432
- return promptMulti('Install which skills?', SKILL_IDS);
515
+ /** @param {string[]} [checkedIds] */
516
+ async function promptSkills(checkedIds) {
517
+ // First run (nothing installed): default to all skills, so <enter> = install everything.
518
+ const defaults = checkedIds?.length ? checkedIds : [...SKILL_IDS];
519
+ return promptMulti('Install which skills?', SKILL_IDS, defaults);
433
520
  }
434
521
 
435
- async function promptAgents() {
436
- return promptMulti('Install for which environments?', AGENT_IDS);
522
+ /** @param {string[]} [checkedIds] */
523
+ async function promptAgents(checkedIds) {
524
+ return promptMulti('Install for which environments?', AGENT_IDS, checkedIds ?? []);
437
525
  }
438
526
 
439
527
  /**
@@ -448,19 +536,6 @@ export async function promptAdrDir(defaultDir = DEFAULT_ADR_DIR) {
448
536
  return normalizeAdrDir(dir.trim() || defaultDir);
449
537
  }
450
538
 
451
- /**
452
- * @returns {Promise<{ enabled: boolean, dir: string }>}
453
- */
454
- export async function promptAdrOptions() {
455
- const enabled = await confirm({
456
- message: 'Use Architecture Decision Records (ADRs) after OpenSpec archive?',
457
- default: false,
458
- });
459
- if (!enabled) return { enabled: false, dir: DEFAULT_ADR_DIR };
460
- const dir = await promptAdrDir(DEFAULT_ADR_DIR);
461
- return { enabled: true, dir };
462
- }
463
-
464
539
  /**
465
540
  * @returns {Promise<boolean>} true = OpenSpec, false = built-in specs engine
466
541
  */
@@ -504,45 +579,38 @@ export function inferAdrFromSkills(skills, adrFlag) {
504
579
  }
505
580
 
506
581
  /**
507
- * Resolve ADR enablement + directory from flags / prompts.
582
+ * Resolve ADR enablement + directory. ADRs turn on when an ADR skill is picked
583
+ * (or --adr); the path is only asked when enabled — never a standalone prompt.
508
584
  * @param {{ adr: boolean | null, adrDir: string | null, skills: string[] }} opts
509
585
  * @returns {Promise<{ enabled: boolean, dir: string }>}
510
586
  */
511
587
  export async function resolveAdrInstallOptions(opts) {
512
- const adrDecision = inferAdrFromSkills(opts.skills, opts.adr);
513
- /** @type {{ enabled: boolean, dir: string }} */
514
- let adrOpts = { enabled: false, dir: DEFAULT_ADR_DIR };
515
-
516
- if (adrDecision === null && process.stdin.isTTY) {
517
- adrOpts = await promptAdrOptions();
518
- } else if (adrDecision === true) {
519
- adrOpts.enabled = true;
520
- adrOpts.dir = opts.adrDir
521
- ? normalizeAdrDir(opts.adrDir)
522
- : process.stdin.isTTY
523
- ? await promptAdrDir(DEFAULT_ADR_DIR)
524
- : DEFAULT_ADR_DIR;
525
- } else if (adrDecision === false) {
526
- adrOpts = {
588
+ const enabled = inferAdrFromSkills(opts.skills, opts.adr) === true;
589
+ if (!enabled) {
590
+ return {
527
591
  enabled: false,
528
592
  dir: opts.adrDir ? normalizeAdrDir(opts.adrDir) : DEFAULT_ADR_DIR,
529
593
  };
530
594
  }
531
-
532
- if (opts.adrDir && adrOpts.enabled) {
533
- adrOpts.dir = normalizeAdrDir(opts.adrDir);
534
- }
535
- return adrOpts;
595
+ const dir = opts.adrDir
596
+ ? normalizeAdrDir(opts.adrDir)
597
+ : process.stdin.isTTY
598
+ ? await promptAdrDir(DEFAULT_ADR_DIR)
599
+ : DEFAULT_ADR_DIR;
600
+ return { enabled: true, dir };
536
601
  }
537
602
 
538
603
  /**
539
- * @param {string[]} skills
540
- * @param {string[]} agents
541
- * @returns {Promise<{ skills: string[], agents: string[] } | number>}
604
+ * @param {string[]} skillsIn
605
+ * @param {string[]} agentsIn
606
+ * @returns {Promise<{ skills: string[], agents: string[], skillsPrompted: boolean, agentsPrompted: boolean } | number>}
542
607
  */
543
608
  async function resolveSkillsAndAgents(skillsIn, agentsIn) {
544
609
  let skills = [...skillsIn];
545
610
  let agents = [...agentsIn];
611
+ let skillsPrompted = false;
612
+ let agentsPrompted = false;
613
+ const installed = installedManagedPairs();
546
614
 
547
615
  if (skills.length === 0) {
548
616
  if (!process.stdin.isTTY) {
@@ -551,7 +619,8 @@ async function resolveSkillsAndAgents(skillsIn, agentsIn) {
551
619
  );
552
620
  return 1;
553
621
  }
554
- skills = await promptSkills();
622
+ skills = await promptSkills([...new Set(installed.map((p) => p.skill))]);
623
+ skillsPrompted = true;
555
624
  }
556
625
 
557
626
  for (const id of skills) {
@@ -568,7 +637,8 @@ async function resolveSkillsAndAgents(skillsIn, agentsIn) {
568
637
  );
569
638
  return 1;
570
639
  }
571
- agents = await promptAgents();
640
+ agents = await promptAgents([...new Set(installed.map((p) => p.agent))]);
641
+ agentsPrompted = true;
572
642
  }
573
643
 
574
644
  for (const id of agents) {
@@ -578,7 +648,7 @@ async function resolveSkillsAndAgents(skillsIn, agentsIn) {
578
648
  }
579
649
  }
580
650
 
581
- return { skills, agents };
651
+ return { skills, agents, skillsPrompted, agentsPrompted };
582
652
  }
583
653
 
584
654
  /**
@@ -628,6 +698,10 @@ export async function runInstall(argv = process.argv.slice(2)) {
628
698
  if (typeof resolved === 'number') return resolved;
629
699
  skills = resolved.skills;
630
700
  agents = resolved.agents;
701
+ // Reconcile (add new, drop deselected) only when the user chose the full set
702
+ // via the pickers — flag-scoped runs (e.g. `forge install`) stay additive.
703
+ const prune =
704
+ opts.prune || (resolved.skillsPrompted && resolved.agentsPrompted);
631
705
 
632
706
  if (opts.uninstall) {
633
707
  const results = uninstallSkillsFromAgents(skills, agents);
@@ -658,7 +732,9 @@ export async function runInstall(argv = process.argv.slice(2)) {
658
732
  adr: { enabled: adrOpts.enabled, dir: adrOpts.dir },
659
733
  });
660
734
 
661
- const results = installSkillsToAgents(skills, agents, { force: opts.force });
735
+ const { results, removed } = prune
736
+ ? reconcileInstall(skills, agents, { force: true, prune: true })
737
+ : { results: installSkillsToAgents(skills, agents, { force: opts.force }), removed: [] };
662
738
  const sources = new Map();
663
739
  for (const r of results) {
664
740
  if (r.skillSource) sources.set(r.skill, r.skillSource);
@@ -666,6 +742,9 @@ export async function runInstall(argv = process.argv.slice(2)) {
666
742
  for (const [skill, src] of sources) {
667
743
  process.stdout.write(`Skill ${skill}: ${src}\n`);
668
744
  }
745
+ for (const r of removed) {
746
+ process.stdout.write(`${r.skill} × ${r.agent}: removed (deselected) → ${r.dest}\n`);
747
+ }
669
748
  for (const r of results) {
670
749
  process.stdout.write(
671
750
  `${r.skill} × ${r.agent}: ${r.status}${r.message ? ` — ${r.message}` : ''} → ${r.dest}\n`,
@@ -6,13 +6,17 @@ import path from 'node:path';
6
6
  import {
7
7
  parseArgs,
8
8
  installSkillsToAgents,
9
+ reconcileInstall,
10
+ installedManagedPairs,
9
11
  listInstallStatus,
10
12
  uninstallSkillsFromAgents,
11
13
  updateOutdatedSkills,
12
14
  readInstallStamp,
15
+ resolveAdrInstallOptions,
13
16
  FORGEKIT_STAMP,
14
17
  SKILL_IDS,
15
18
  AGENT_IDS,
19
+ AGENTS,
16
20
  } from './install.mjs';
17
21
 
18
22
  test('parseArgs supports multi skills and agents', () => {
@@ -89,6 +93,78 @@ test('uninstallSkillsFromAgents removes installed dirs', () => {
89
93
  }
90
94
  });
91
95
 
96
+ test('expanded environments resolve to their global skills dirs', () => {
97
+ const home = '/home/u';
98
+ assert.ok(AGENT_IDS.includes('copilot'));
99
+ assert.ok(AGENT_IDS.includes('windsurf'));
100
+ assert.equal(
101
+ AGENTS.copilot.skillDir(home, 'forge'),
102
+ path.join(home, '.copilot', 'skills', 'forge'),
103
+ );
104
+ assert.equal(
105
+ AGENTS.windsurf.skillDir(home, 'forge'),
106
+ path.join(home, '.codeium', 'windsurf', 'skills', 'forge'),
107
+ );
108
+ assert.equal(
109
+ AGENTS.opencode.skillDir(home, 'forge'),
110
+ path.join(home, '.config', 'opencode', 'skills', 'forge'),
111
+ );
112
+ });
113
+
114
+ test('reconcileInstall prunes deselected pairs and remembers installs', () => {
115
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-recon-'));
116
+ try {
117
+ // Start: forge on cursor + claude.
118
+ reconcileInstall(['forge'], ['cursor', 'claude'], { home, prune: true });
119
+ let managed = installedManagedPairs(home);
120
+ assert.equal(managed.length, 2);
121
+
122
+ // Re-select: forge on cursor only → claude pair pruned.
123
+ const { removed } = reconcileInstall(['forge'], ['cursor'], {
124
+ home,
125
+ prune: true,
126
+ });
127
+ assert.equal(removed.length, 1);
128
+ assert.equal(removed[0].agent, 'claude');
129
+ managed = installedManagedPairs(home);
130
+ assert.deepEqual(
131
+ managed.map((p) => `${p.skill}:${p.agent}`),
132
+ ['forge:cursor'],
133
+ );
134
+ assert.ok(!fs.existsSync(path.join(home, '.claude', 'skills', 'forge')));
135
+ } finally {
136
+ fs.rmSync(home, { recursive: true, force: true });
137
+ }
138
+ });
139
+
140
+ test('reconcileInstall without prune is additive (no removals)', () => {
141
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-recon2-'));
142
+ try {
143
+ reconcileInstall(['forge'], ['cursor', 'claude'], { home, prune: true });
144
+ const { removed } = reconcileInstall(['forge'], ['cursor'], { home });
145
+ assert.equal(removed.length, 0);
146
+ assert.equal(installedManagedPairs(home).length, 2);
147
+ } finally {
148
+ fs.rmSync(home, { recursive: true, force: true });
149
+ }
150
+ });
151
+
152
+ test('resolveAdrInstallOptions: no ADR skill selected → disabled, no path prompt', async () => {
153
+ const off = await resolveAdrInstallOptions({
154
+ adr: null,
155
+ adrDir: null,
156
+ skills: ['forge'],
157
+ });
158
+ assert.equal(off.enabled, false);
159
+ const on = await resolveAdrInstallOptions({
160
+ adr: null,
161
+ adrDir: 'docs/decisions',
162
+ skills: ['forge', 'archive-to-adr'],
163
+ });
164
+ assert.equal(on.enabled, true);
165
+ assert.equal(on.dir, 'docs/decisions');
166
+ });
167
+
92
168
  test('updateOutdatedSkills refreshes unversioned installs', () => {
93
169
  const home = fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-upd-'));
94
170
  try {