@izkac/forgekit 0.1.2 → 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 +5 -3
- package/bin/forge.mjs +1 -1
- package/package.json +49 -46
- package/src/init.mjs +48 -57
- package/src/install.mjs +158 -115
- package/src/install.test.mjs +76 -0
- package/src/menu-select.mjs +0 -49
- package/src/menu-select.test.mjs +0 -52
package/README.md
CHANGED
|
@@ -15,17 +15,19 @@ npm i -g @izkac/forgekit
|
|
|
15
15
|
forgekit install
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Interactive install
|
|
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: `~/.
|
|
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/bin/forge.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,46 +1,49 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@izkac/forgekit",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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
|
@@ -11,10 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
|
-
import readline from 'node:readline/promises';
|
|
15
14
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
15
|
import { spawnSync } from 'node:child_process';
|
|
17
|
-
import {
|
|
16
|
+
import { checkbox, confirm, input } from '@inquirer/prompts';
|
|
18
17
|
import {
|
|
19
18
|
DEFAULT_ADR_DIR,
|
|
20
19
|
disableProjectAdr,
|
|
@@ -22,7 +21,6 @@ import {
|
|
|
22
21
|
normalizeAdrDir,
|
|
23
22
|
scaffoldAdr,
|
|
24
23
|
} from './adr.mjs';
|
|
25
|
-
import { parseMenuSelection } from './menu-select.mjs';
|
|
26
24
|
import {
|
|
27
25
|
DEFAULT_SPECS_DIR,
|
|
28
26
|
hasOpenSpecConfig,
|
|
@@ -391,27 +389,36 @@ export function initProject(selected, opts) {
|
|
|
391
389
|
return report;
|
|
392
390
|
}
|
|
393
391
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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);
|
|
413
|
+
return checkbox({
|
|
414
|
+
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
|
+
],
|
|
420
|
+
required: true,
|
|
421
|
+
});
|
|
415
422
|
}
|
|
416
423
|
|
|
417
424
|
/**
|
|
@@ -419,19 +426,11 @@ async function promptAgents() {
|
|
|
419
426
|
* @returns {Promise<boolean>} true = user accepted OpenSpec setup
|
|
420
427
|
*/
|
|
421
428
|
async function promptOpenSpecSetup() {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
)
|
|
428
|
-
)
|
|
429
|
-
.trim()
|
|
430
|
-
.toLowerCase();
|
|
431
|
-
return !(yn === 'n' || yn === 'no');
|
|
432
|
-
} finally {
|
|
433
|
-
rl.close();
|
|
434
|
-
}
|
|
429
|
+
return confirm({
|
|
430
|
+
message:
|
|
431
|
+
'OpenSpec is not set up in this project. Install and set it up now? (No = built-in specs engine)',
|
|
432
|
+
default: true,
|
|
433
|
+
});
|
|
435
434
|
}
|
|
436
435
|
|
|
437
436
|
/**
|
|
@@ -490,25 +489,16 @@ async function resolveInitPlanEngine(opts) {
|
|
|
490
489
|
* @returns {Promise<{ enabled: boolean, dir: string }>}
|
|
491
490
|
*/
|
|
492
491
|
async function promptAdrForInit(defaultDir = DEFAULT_ADR_DIR) {
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
enabled = yn === 'y' || yn === 'yes';
|
|
504
|
-
if (!enabled) return { enabled: false, dir: defaultDir };
|
|
505
|
-
const dirAnswer = (
|
|
506
|
-
await rl.question(`ADR directory inside the repo [${defaultDir}]: `)
|
|
507
|
-
).trim();
|
|
508
|
-
return { enabled: true, dir: normalizeAdrDir(dirAnswer || defaultDir) };
|
|
509
|
-
} finally {
|
|
510
|
-
rl.close();
|
|
511
|
-
}
|
|
492
|
+
const enabled = await confirm({
|
|
493
|
+
message: 'Use Architecture Decision Records (ADRs) in this project?',
|
|
494
|
+
default: false,
|
|
495
|
+
});
|
|
496
|
+
if (!enabled) return { enabled: false, dir: defaultDir };
|
|
497
|
+
const dir = await input({
|
|
498
|
+
message: 'ADR directory inside the repo',
|
|
499
|
+
default: defaultDir,
|
|
500
|
+
});
|
|
501
|
+
return { enabled: true, dir: normalizeAdrDir(dir.trim() || defaultDir) };
|
|
512
502
|
}
|
|
513
503
|
|
|
514
504
|
async function main(argv = process.argv.slice(2)) {
|
|
@@ -526,7 +516,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
526
516
|
);
|
|
527
517
|
return 1;
|
|
528
518
|
}
|
|
529
|
-
selected = await promptAgents();
|
|
519
|
+
selected = await promptAgents(opts.cwd);
|
|
530
520
|
}
|
|
531
521
|
|
|
532
522
|
const planEngine = await resolveInitPlanEngine({
|
|
@@ -566,6 +556,7 @@ if (isDirect) {
|
|
|
566
556
|
main()
|
|
567
557
|
.then((code) => process.exit(code))
|
|
568
558
|
.catch((err) => {
|
|
559
|
+
if (err?.name === 'ExitPromptError') process.exit(130);
|
|
569
560
|
process.stderr.write(`${err.message || err}\n`);
|
|
570
561
|
process.exit(1);
|
|
571
562
|
});
|
package/src/install.mjs
CHANGED
|
@@ -16,9 +16,8 @@
|
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import os from 'node:os';
|
|
18
18
|
import path from 'node:path';
|
|
19
|
-
import readline from 'node:readline/promises';
|
|
20
19
|
import { pathToFileURL } from 'node:url';
|
|
21
|
-
import {
|
|
20
|
+
import { checkbox, input, select } from '@inquirer/prompts';
|
|
22
21
|
import {
|
|
23
22
|
ADR_SKILLS,
|
|
24
23
|
DEFAULT_ADR_DIR,
|
|
@@ -28,7 +27,6 @@ import {
|
|
|
28
27
|
scaffoldAdr,
|
|
29
28
|
disableProjectAdr,
|
|
30
29
|
} from './adr.mjs';
|
|
31
|
-
import { parseMenuSelection } from './menu-select.mjs';
|
|
32
30
|
import { saveUserPlanEngine } from './plan-engine.mjs';
|
|
33
31
|
import { hashDirectory, packageVersion, resolveAsset } from './paths.mjs';
|
|
34
32
|
|
|
@@ -60,20 +58,42 @@ export const SKILLS = {
|
|
|
60
58
|
|
|
61
59
|
export const SKILL_IDS = Object.freeze(Object.keys(SKILLS));
|
|
62
60
|
|
|
63
|
-
/**
|
|
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
|
+
*/
|
|
64
66
|
export const AGENTS = {
|
|
65
|
-
cursor: {
|
|
66
|
-
label: 'Cursor',
|
|
67
|
-
skillDir: (home, skillId) => path.join(home, '.cursor', 'skills', skillId),
|
|
68
|
-
},
|
|
69
67
|
claude: {
|
|
70
68
|
label: 'Claude Code',
|
|
71
69
|
skillDir: (home, skillId) => path.join(home, '.claude', 'skills', skillId),
|
|
72
70
|
},
|
|
71
|
+
cursor: {
|
|
72
|
+
label: 'Cursor',
|
|
73
|
+
skillDir: (home, skillId) => path.join(home, '.cursor', 'skills', skillId),
|
|
74
|
+
},
|
|
73
75
|
codex: {
|
|
74
76
|
label: 'Codex CLI',
|
|
75
77
|
skillDir: (home, skillId) => path.join(home, '.codex', 'skills', skillId),
|
|
76
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
|
+
},
|
|
77
97
|
};
|
|
78
98
|
|
|
79
99
|
export const AGENT_IDS = Object.freeze(Object.keys(AGENTS));
|
|
@@ -103,6 +123,7 @@ export function parseArgs(argv) {
|
|
|
103
123
|
list: false,
|
|
104
124
|
help: false,
|
|
105
125
|
force: false,
|
|
126
|
+
prune: false,
|
|
106
127
|
update: false,
|
|
107
128
|
uninstall: false,
|
|
108
129
|
/** @type {boolean | null} null = unset (prompt / infer) */
|
|
@@ -129,10 +150,15 @@ export function parseArgs(argv) {
|
|
|
129
150
|
else if (arg === '--update') opts.update = true;
|
|
130
151
|
else if (arg === '--uninstall') opts.uninstall = true;
|
|
131
152
|
else if (arg === '--force' || arg === '-f') opts.force = true;
|
|
153
|
+
else if (arg === '--prune') opts.prune = true;
|
|
132
154
|
else if (arg === '--help' || arg === '-h') opts.help = true;
|
|
133
155
|
else if (arg === '--cursor') opts.agents.push('cursor');
|
|
134
156
|
else if (arg === '--claude' || arg === '--claude-code') opts.agents.push('claude');
|
|
135
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');
|
|
136
162
|
else if (arg === '--adr') opts.adr = true;
|
|
137
163
|
else if (arg === '--no-adr') opts.adr = false;
|
|
138
164
|
else if (arg === '--adr-dir') opts.adrDir = argv[++i];
|
|
@@ -166,8 +192,10 @@ Options:
|
|
|
166
192
|
--agents <ids> Comma list: ${AGENT_IDS.join(', ')}
|
|
167
193
|
--all-skills Install every known skill
|
|
168
194
|
--all-agents Install for every agent environment
|
|
169
|
-
--cursor/--claude/--codex
|
|
195
|
+
--cursor/--claude/--codex/--copilot/--gemini/--windsurf/--opencode
|
|
170
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)
|
|
171
199
|
--openspec Prefer OpenSpec as the planning engine (save user default)
|
|
172
200
|
--no-openspec Prefer the built-in specs engine (save user default)
|
|
173
201
|
--adr Enable ADRs (install ADR skills; save user default)
|
|
@@ -182,9 +210,12 @@ Options:
|
|
|
182
210
|
--force, -f Overwrite existing skill directories
|
|
183
211
|
--help
|
|
184
212
|
|
|
185
|
-
Interactive (TTY) when skills and/or agents are omitted
|
|
186
|
-
|
|
187
|
-
|
|
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.
|
|
188
219
|
|
|
189
220
|
Aliases:
|
|
190
221
|
forge install […] → forgekit install --skills forge […]
|
|
@@ -357,6 +388,54 @@ export function uninstallSkillsFromAgents(skillIds, agentIds, opts = {}) {
|
|
|
357
388
|
return results;
|
|
358
389
|
}
|
|
359
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
|
+
|
|
360
439
|
/**
|
|
361
440
|
* Reinstall skills that are outdated or unversioned for agents that already have them.
|
|
362
441
|
* @param {{ home?: string, skills?: string[], agents?: string[] }} [opts]
|
|
@@ -415,42 +494,34 @@ export function listInstallStatus(opts = {}) {
|
|
|
415
494
|
}
|
|
416
495
|
|
|
417
496
|
/**
|
|
418
|
-
* @param {string}
|
|
419
|
-
* @param {
|
|
420
|
-
* @param {string[]}
|
|
497
|
+
* @param {string} message
|
|
498
|
+
* @param {string[]} ids
|
|
499
|
+
* @param {string[]} [checkedIds] pre-selected (remembered from prior install)
|
|
421
500
|
* @returns {Promise<string[]>}
|
|
422
501
|
*/
|
|
423
|
-
async function promptMulti(
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
process.stdout.write(` ${allNum}) All\n`);
|
|
435
|
-
for (;;) {
|
|
436
|
-
const answer = await rl.question(`Choice(s) [1-${allNum}]: `);
|
|
437
|
-
const parsed = parseMenuSelection(answer, map, allIds, allNum);
|
|
438
|
-
if (parsed.ok) return parsed.ids;
|
|
439
|
-
process.stdout.write(`${parsed.error}\n`);
|
|
440
|
-
}
|
|
441
|
-
} finally {
|
|
442
|
-
rl.close();
|
|
443
|
-
}
|
|
502
|
+
async function promptMulti(message, ids, checkedIds = []) {
|
|
503
|
+
const checked = new Set(checkedIds);
|
|
504
|
+
return checkbox({
|
|
505
|
+
message,
|
|
506
|
+
choices: ids.map((id) => ({
|
|
507
|
+
value: id,
|
|
508
|
+
name: SKILLS[id]?.label ?? AGENTS[id]?.label ?? id,
|
|
509
|
+
checked: checked.has(id),
|
|
510
|
+
})),
|
|
511
|
+
required: true,
|
|
512
|
+
});
|
|
444
513
|
}
|
|
445
514
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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);
|
|
449
520
|
}
|
|
450
521
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
return promptMulti('Install for which environments?',
|
|
522
|
+
/** @param {string[]} [checkedIds] */
|
|
523
|
+
async function promptAgents(checkedIds) {
|
|
524
|
+
return promptMulti('Install for which environments?', AGENT_IDS, checkedIds ?? []);
|
|
454
525
|
}
|
|
455
526
|
|
|
456
527
|
/**
|
|
@@ -458,57 +529,24 @@ async function promptAgents() {
|
|
|
458
529
|
* @returns {Promise<string>}
|
|
459
530
|
*/
|
|
460
531
|
export async function promptAdrDir(defaultDir = DEFAULT_ADR_DIR) {
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
return normalizeAdrDir(dirAnswer || defaultDir);
|
|
467
|
-
} finally {
|
|
468
|
-
rl.close();
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
/**
|
|
473
|
-
* @returns {Promise<{ enabled: boolean, dir: string }>}
|
|
474
|
-
*/
|
|
475
|
-
export async function promptAdrOptions() {
|
|
476
|
-
const rl = readline.createInterface({ input, output });
|
|
477
|
-
let enabled = false;
|
|
478
|
-
try {
|
|
479
|
-
const yn = (
|
|
480
|
-
await rl.question(
|
|
481
|
-
'Use Architecture Decision Records (ADRs) after OpenSpec archive? [y/N] ',
|
|
482
|
-
)
|
|
483
|
-
)
|
|
484
|
-
.trim()
|
|
485
|
-
.toLowerCase();
|
|
486
|
-
enabled = yn === 'y' || yn === 'yes';
|
|
487
|
-
} finally {
|
|
488
|
-
rl.close();
|
|
489
|
-
}
|
|
490
|
-
if (!enabled) return { enabled: false, dir: DEFAULT_ADR_DIR };
|
|
491
|
-
const dir = await promptAdrDir(DEFAULT_ADR_DIR);
|
|
492
|
-
return { enabled: true, dir };
|
|
532
|
+
const dir = await input({
|
|
533
|
+
message: 'ADR directory inside each repo',
|
|
534
|
+
default: defaultDir,
|
|
535
|
+
});
|
|
536
|
+
return normalizeAdrDir(dir.trim() || defaultDir);
|
|
493
537
|
}
|
|
494
538
|
|
|
495
539
|
/**
|
|
496
540
|
* @returns {Promise<boolean>} true = OpenSpec, false = built-in specs engine
|
|
497
541
|
*/
|
|
498
542
|
export async function promptOpenSpec() {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
.trim()
|
|
507
|
-
.toLowerCase();
|
|
508
|
-
return !(yn === 'n' || yn === 'no');
|
|
509
|
-
} finally {
|
|
510
|
-
rl.close();
|
|
511
|
-
}
|
|
543
|
+
return select({
|
|
544
|
+
message: 'Planning engine?',
|
|
545
|
+
choices: [
|
|
546
|
+
{ value: true, name: 'OpenSpec (vendor CLI)' },
|
|
547
|
+
{ value: false, name: 'Built-in specs engine' },
|
|
548
|
+
],
|
|
549
|
+
});
|
|
512
550
|
}
|
|
513
551
|
|
|
514
552
|
/**
|
|
@@ -541,45 +579,38 @@ export function inferAdrFromSkills(skills, adrFlag) {
|
|
|
541
579
|
}
|
|
542
580
|
|
|
543
581
|
/**
|
|
544
|
-
* Resolve ADR enablement + directory
|
|
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.
|
|
545
584
|
* @param {{ adr: boolean | null, adrDir: string | null, skills: string[] }} opts
|
|
546
585
|
* @returns {Promise<{ enabled: boolean, dir: string }>}
|
|
547
586
|
*/
|
|
548
587
|
export async function resolveAdrInstallOptions(opts) {
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
if (adrDecision === null && process.stdin.isTTY) {
|
|
554
|
-
adrOpts = await promptAdrOptions();
|
|
555
|
-
} else if (adrDecision === true) {
|
|
556
|
-
adrOpts.enabled = true;
|
|
557
|
-
adrOpts.dir = opts.adrDir
|
|
558
|
-
? normalizeAdrDir(opts.adrDir)
|
|
559
|
-
: process.stdin.isTTY
|
|
560
|
-
? await promptAdrDir(DEFAULT_ADR_DIR)
|
|
561
|
-
: DEFAULT_ADR_DIR;
|
|
562
|
-
} else if (adrDecision === false) {
|
|
563
|
-
adrOpts = {
|
|
588
|
+
const enabled = inferAdrFromSkills(opts.skills, opts.adr) === true;
|
|
589
|
+
if (!enabled) {
|
|
590
|
+
return {
|
|
564
591
|
enabled: false,
|
|
565
592
|
dir: opts.adrDir ? normalizeAdrDir(opts.adrDir) : DEFAULT_ADR_DIR,
|
|
566
593
|
};
|
|
567
594
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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 };
|
|
573
601
|
}
|
|
574
602
|
|
|
575
603
|
/**
|
|
576
|
-
* @param {string[]}
|
|
577
|
-
* @param {string[]}
|
|
578
|
-
* @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>}
|
|
579
607
|
*/
|
|
580
608
|
async function resolveSkillsAndAgents(skillsIn, agentsIn) {
|
|
581
609
|
let skills = [...skillsIn];
|
|
582
610
|
let agents = [...agentsIn];
|
|
611
|
+
let skillsPrompted = false;
|
|
612
|
+
let agentsPrompted = false;
|
|
613
|
+
const installed = installedManagedPairs();
|
|
583
614
|
|
|
584
615
|
if (skills.length === 0) {
|
|
585
616
|
if (!process.stdin.isTTY) {
|
|
@@ -588,7 +619,8 @@ async function resolveSkillsAndAgents(skillsIn, agentsIn) {
|
|
|
588
619
|
);
|
|
589
620
|
return 1;
|
|
590
621
|
}
|
|
591
|
-
skills = await promptSkills();
|
|
622
|
+
skills = await promptSkills([...new Set(installed.map((p) => p.skill))]);
|
|
623
|
+
skillsPrompted = true;
|
|
592
624
|
}
|
|
593
625
|
|
|
594
626
|
for (const id of skills) {
|
|
@@ -605,7 +637,8 @@ async function resolveSkillsAndAgents(skillsIn, agentsIn) {
|
|
|
605
637
|
);
|
|
606
638
|
return 1;
|
|
607
639
|
}
|
|
608
|
-
agents = await promptAgents();
|
|
640
|
+
agents = await promptAgents([...new Set(installed.map((p) => p.agent))]);
|
|
641
|
+
agentsPrompted = true;
|
|
609
642
|
}
|
|
610
643
|
|
|
611
644
|
for (const id of agents) {
|
|
@@ -615,7 +648,7 @@ async function resolveSkillsAndAgents(skillsIn, agentsIn) {
|
|
|
615
648
|
}
|
|
616
649
|
}
|
|
617
650
|
|
|
618
|
-
return { skills, agents };
|
|
651
|
+
return { skills, agents, skillsPrompted, agentsPrompted };
|
|
619
652
|
}
|
|
620
653
|
|
|
621
654
|
/**
|
|
@@ -665,6 +698,10 @@ export async function runInstall(argv = process.argv.slice(2)) {
|
|
|
665
698
|
if (typeof resolved === 'number') return resolved;
|
|
666
699
|
skills = resolved.skills;
|
|
667
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);
|
|
668
705
|
|
|
669
706
|
if (opts.uninstall) {
|
|
670
707
|
const results = uninstallSkillsFromAgents(skills, agents);
|
|
@@ -695,7 +732,9 @@ export async function runInstall(argv = process.argv.slice(2)) {
|
|
|
695
732
|
adr: { enabled: adrOpts.enabled, dir: adrOpts.dir },
|
|
696
733
|
});
|
|
697
734
|
|
|
698
|
-
const results
|
|
735
|
+
const { results, removed } = prune
|
|
736
|
+
? reconcileInstall(skills, agents, { force: true, prune: true })
|
|
737
|
+
: { results: installSkillsToAgents(skills, agents, { force: opts.force }), removed: [] };
|
|
699
738
|
const sources = new Map();
|
|
700
739
|
for (const r of results) {
|
|
701
740
|
if (r.skillSource) sources.set(r.skill, r.skillSource);
|
|
@@ -703,6 +742,9 @@ export async function runInstall(argv = process.argv.slice(2)) {
|
|
|
703
742
|
for (const [skill, src] of sources) {
|
|
704
743
|
process.stdout.write(`Skill ${skill}: ${src}\n`);
|
|
705
744
|
}
|
|
745
|
+
for (const r of removed) {
|
|
746
|
+
process.stdout.write(`${r.skill} × ${r.agent}: removed (deselected) → ${r.dest}\n`);
|
|
747
|
+
}
|
|
706
748
|
for (const r of results) {
|
|
707
749
|
process.stdout.write(
|
|
708
750
|
`${r.skill} × ${r.agent}: ${r.status}${r.message ? ` — ${r.message}` : ''} → ${r.dest}\n`,
|
|
@@ -763,6 +805,7 @@ if (isDirect) {
|
|
|
763
805
|
runInstall()
|
|
764
806
|
.then((code) => process.exit(code))
|
|
765
807
|
.catch((err) => {
|
|
808
|
+
if (err?.name === 'ExitPromptError') process.exit(130);
|
|
766
809
|
process.stderr.write(`${err.message || err}\n`);
|
|
767
810
|
process.exit(1);
|
|
768
811
|
});
|
package/src/install.test.mjs
CHANGED
|
@@ -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 {
|
package/src/menu-select.mjs
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared numbered-menu selection (one, many, or all).
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Parse a menu answer into selected ids.
|
|
7
|
-
* Accepts one number, several (comma/space), or the All option.
|
|
8
|
-
*
|
|
9
|
-
* @param {string} answer
|
|
10
|
-
* @param {Record<string, string>} map number → id
|
|
11
|
-
* @param {string[]} allIds
|
|
12
|
-
* @param {string} allNum
|
|
13
|
-
* @returns {{ ok: true, ids: string[] } | { ok: false, error: string }}
|
|
14
|
-
*/
|
|
15
|
-
export function parseMenuSelection(answer, map, allIds, allNum) {
|
|
16
|
-
const raw = String(answer ?? '').trim();
|
|
17
|
-
if (!raw) {
|
|
18
|
-
return {
|
|
19
|
-
ok: false,
|
|
20
|
-
error: `Enter one or more numbers (e.g. 1 or 1,3) or ${allNum} for all`,
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
if (raw === allNum || /^all$/i.test(raw)) {
|
|
24
|
-
return { ok: true, ids: [...allIds] };
|
|
25
|
-
}
|
|
26
|
-
const tokens = raw.split(/[,\s]+/).filter(Boolean);
|
|
27
|
-
/** @type {string[]} */
|
|
28
|
-
const ids = [];
|
|
29
|
-
/** @type {string[]} */
|
|
30
|
-
const bad = [];
|
|
31
|
-
for (const t of tokens) {
|
|
32
|
-
if (t === allNum || /^all$/i.test(t)) {
|
|
33
|
-
return { ok: true, ids: [...allIds] };
|
|
34
|
-
}
|
|
35
|
-
const id = map[t];
|
|
36
|
-
if (!id) bad.push(t);
|
|
37
|
-
else if (!ids.includes(id)) ids.push(id);
|
|
38
|
-
}
|
|
39
|
-
if (bad.length) {
|
|
40
|
-
return {
|
|
41
|
-
ok: false,
|
|
42
|
-
error: `Unknown choice(s): ${bad.join(', ')}. Use listed numbers or ${allNum} for all`,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
if (ids.length === 0) {
|
|
46
|
-
return { ok: false, error: 'Nothing selected' };
|
|
47
|
-
}
|
|
48
|
-
return { ok: true, ids };
|
|
49
|
-
}
|
package/src/menu-select.test.mjs
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { parseMenuSelection } from './menu-select.mjs';
|
|
4
|
-
|
|
5
|
-
const map = { 1: 'forge', 2: 'review', 3: 'adr', 4: 'git' };
|
|
6
|
-
const allIds = ['forge', 'review', 'adr', 'git'];
|
|
7
|
-
const allNum = '5';
|
|
8
|
-
|
|
9
|
-
test('parseMenuSelection: single choice', () => {
|
|
10
|
-
assert.deepEqual(parseMenuSelection('1', map, allIds, allNum), {
|
|
11
|
-
ok: true,
|
|
12
|
-
ids: ['forge'],
|
|
13
|
-
});
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test('parseMenuSelection: multiple comma-separated', () => {
|
|
17
|
-
assert.deepEqual(parseMenuSelection('1,3', map, allIds, allNum), {
|
|
18
|
-
ok: true,
|
|
19
|
-
ids: ['forge', 'adr'],
|
|
20
|
-
});
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test('parseMenuSelection: multiple space-separated', () => {
|
|
24
|
-
assert.deepEqual(parseMenuSelection('2 4', map, allIds, allNum), {
|
|
25
|
-
ok: true,
|
|
26
|
-
ids: ['review', 'git'],
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test('parseMenuSelection: all via number or word', () => {
|
|
31
|
-
assert.deepEqual(parseMenuSelection('5', map, allIds, allNum), {
|
|
32
|
-
ok: true,
|
|
33
|
-
ids: [...allIds],
|
|
34
|
-
});
|
|
35
|
-
assert.deepEqual(parseMenuSelection('all', map, allIds, allNum), {
|
|
36
|
-
ok: true,
|
|
37
|
-
ids: [...allIds],
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test('parseMenuSelection: empty and unknown rejected', () => {
|
|
42
|
-
assert.equal(parseMenuSelection('', map, allIds, allNum).ok, false);
|
|
43
|
-
assert.equal(parseMenuSelection('9', map, allIds, allNum).ok, false);
|
|
44
|
-
assert.equal(parseMenuSelection('1,9', map, allIds, allNum).ok, false);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test('parseMenuSelection: dedupes repeats', () => {
|
|
48
|
-
assert.deepEqual(parseMenuSelection('1,1,2', map, allIds, allNum), {
|
|
49
|
-
ok: true,
|
|
50
|
-
ids: ['forge', 'review'],
|
|
51
|
-
});
|
|
52
|
-
});
|