@phuc1403/musketeer 0.1.0 → 0.2.1
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 +20 -13
- package/bin/musketeer.js +21 -23
- package/manifest.json +2 -8
- package/package.json +1 -1
- package/src/muster.js +51 -19
- package/src/promote.js +81 -0
- package/src/reconcile.js +2 -20
- package/src/schema.js +1 -3
package/README.md
CHANGED
|
@@ -12,26 +12,33 @@ npm i -g @phuc1403/musketeer
|
|
|
12
12
|
## Use
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
musketeer
|
|
16
|
-
musketeer
|
|
17
|
-
musketeer
|
|
18
|
-
musketeer --help
|
|
15
|
+
musketeer muster # open the muster — pick your company (multi-select) → reconcile
|
|
16
|
+
musketeer promote # update the musketeer CLI itself to the latest published version
|
|
17
|
+
musketeer # show usage
|
|
18
|
+
musketeer --help # show usage
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
`musketeer` is **idempotent**: the picker pre-checks your current company (from `.musketeer.json`),
|
|
21
|
+
`musketeer muster` is **idempotent**: the picker pre-checks your current company (from `.musketeer.json`),
|
|
22
22
|
and confirming applies the diff — install newly-checked, remove newly-unchecked, regenerate
|
|
23
23
|
`settings.json`, and auto-install any missing prerequisites.
|
|
24
24
|
|
|
25
|
+
`musketeer promote` upgrades the **CLI tool** itself (not your project's company): it checks the npm
|
|
26
|
+
registry, and if a newer version exists, runs `npm i -g @phuc1403/musketeer@latest`. _muster = which
|
|
27
|
+
musketeers are in this project; promote = upgrade the binary._
|
|
28
|
+
|
|
25
29
|
## Musketeers (v1)
|
|
26
30
|
|
|
27
|
-
| Musketeer | Default | What it adds |
|
|
28
|
-
|
|
29
|
-
| **core** | always (locked) | research, handoff, skill-creator · statusline, usage-context, format-json hooks |
|
|
30
|
-
| **architecture** |
|
|
31
|
-
| **hallmark** |
|
|
32
|
-
| **code-review** |
|
|
33
|
-
| **dotnet** |
|
|
34
|
-
| **design-docs** |
|
|
31
|
+
| Musketeer | Default in muster | What it adds |
|
|
32
|
+
|-----------|-------------------|--------------|
|
|
33
|
+
| **core** | always on (locked, hidden) | research, handoff, skill-creator · statusline, usage-context, format-json hooks |
|
|
34
|
+
| **architecture** | off | adr-writer, architecture-characteristic-writer, context-map · CML validation hook |
|
|
35
|
+
| **hallmark** | off | hallmark, hallmark-explore, hallmark-loop · auditor/explorer agents |
|
|
36
|
+
| **code-review** | off | code-review skill + code-reviewer agent |
|
|
37
|
+
| **dotnet** | off | tdd, knowledge-crunching · EF migration guard hook |
|
|
38
|
+
| **design-docs** | off | inject-design-docs SessionStart hook |
|
|
39
|
+
|
|
40
|
+
The muster starts every musketeer **unselected**, except ones you already installed (pre-checked from
|
|
41
|
+
`.musketeer.json`). **core** is always included and isn't shown as a toggle — it can't be removed.
|
|
35
42
|
|
|
36
43
|
Vocabulary: **musketeer** = a selectable unit · **muster** = the picker · **company** = your recruited set.
|
|
37
44
|
|
package/bin/musketeer.js
CHANGED
|
@@ -6,25 +6,25 @@ const { parseArgs } = require('node:util');
|
|
|
6
6
|
const USAGE = `musketeer — scaffold a curated company of Claude Code musketeers into ./.claude
|
|
7
7
|
|
|
8
8
|
Usage:
|
|
9
|
-
musketeer
|
|
10
|
-
musketeer
|
|
11
|
-
musketeer
|
|
12
|
-
musketeer --help
|
|
9
|
+
musketeer muster Open the muster — pick your company (multi-select), then reconcile
|
|
10
|
+
musketeer promote Update the musketeer CLI itself to the latest published version
|
|
11
|
+
musketeer Show this help
|
|
12
|
+
musketeer --help Show this help
|
|
13
13
|
|
|
14
14
|
Notes:
|
|
15
|
+
* "muster" changes which musketeers are in THIS project; "promote" upgrades the CLI itself.
|
|
15
16
|
* "core" is always installed and locked — it cannot be removed.
|
|
16
|
-
*
|
|
17
|
+
* The muster is multi-select: check every musketeer you want, then confirm.
|
|
18
|
+
* Re-running is idempotent: the picker pre-checks your current company; confirm applies the diff.
|
|
17
19
|
* settings.json is GENERATED per-selection; missing prereqs are auto-installed (announced).`;
|
|
18
20
|
|
|
19
21
|
function parse(argv) {
|
|
20
22
|
return parseArgs({
|
|
21
23
|
args: argv,
|
|
22
24
|
options: {
|
|
23
|
-
with: { type: 'string' },
|
|
24
|
-
list: { type: 'boolean', default: false },
|
|
25
25
|
help: { type: 'boolean', short: 'h', default: false },
|
|
26
26
|
},
|
|
27
|
-
allowPositionals:
|
|
27
|
+
allowPositionals: true,
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -38,32 +38,30 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
38
38
|
return;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
const { values } = parsed;
|
|
41
|
+
const { values, positionals } = parsed;
|
|
42
|
+
const command = positionals[0];
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
// `musketeer` alone (or --help) → usage.
|
|
45
|
+
if (values.help || !command) {
|
|
44
46
|
process.stdout.write(`${USAGE}\n`);
|
|
45
47
|
return;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
await reconcile.list();
|
|
50
|
+
if (command === 'muster') {
|
|
51
|
+
// Lazy-require so help never pays the cost of loading the engine/manifest.
|
|
52
|
+
const reconcile = require('../src/reconcile');
|
|
53
|
+
await reconcile.run({ desiredIds: null, interactive: true });
|
|
53
54
|
return;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
if (
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
.map((s) => s.trim())
|
|
60
|
-
.filter(Boolean);
|
|
61
|
-
await reconcile.run({ desiredIds: ids, interactive: false });
|
|
57
|
+
if (command === 'promote') {
|
|
58
|
+
const promote = require('../src/promote');
|
|
59
|
+
await promote.run();
|
|
62
60
|
return;
|
|
63
61
|
}
|
|
64
62
|
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
process.stderr.write(`Unknown command "${command}".\n\n${USAGE}\n`);
|
|
64
|
+
process.exitCode = 2;
|
|
67
65
|
}
|
|
68
66
|
|
|
69
67
|
main().catch((err) => {
|
package/manifest.json
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
"label": "core",
|
|
6
6
|
"description": "Always-on foundation: research, handoff, skill-creator + statusline, usage-context & format-json hooks.",
|
|
7
7
|
"locked": true,
|
|
8
|
-
"defaultChecked": true,
|
|
9
8
|
"deps": [],
|
|
10
9
|
"files": [
|
|
11
10
|
"skills/research/**",
|
|
@@ -50,7 +49,6 @@
|
|
|
50
49
|
"label": "architecture",
|
|
51
50
|
"description": "DDD architecture authoring: adr-writer, architecture-characteristic-writer, context-map (CML) + validation hook.",
|
|
52
51
|
"locked": false,
|
|
53
|
-
"defaultChecked": true,
|
|
54
52
|
"deps": [],
|
|
55
53
|
"files": [
|
|
56
54
|
"skills/adr-writer/**",
|
|
@@ -72,7 +70,6 @@
|
|
|
72
70
|
"label": "hallmark",
|
|
73
71
|
"description": "Hallmark design system: hallmark rubric, hallmark-explore, hallmark-loop + auditor/explorer agents.",
|
|
74
72
|
"locked": false,
|
|
75
|
-
"defaultChecked": true,
|
|
76
73
|
"deps": [],
|
|
77
74
|
"files": [
|
|
78
75
|
"skills/hallmark/**",
|
|
@@ -88,7 +85,6 @@
|
|
|
88
85
|
"label": "code-review",
|
|
89
86
|
"description": "Adversarial code review skill + code-reviewer agent (commit / pending / PR modes).",
|
|
90
87
|
"locked": false,
|
|
91
|
-
"defaultChecked": true,
|
|
92
88
|
"deps": [],
|
|
93
89
|
"files": [
|
|
94
90
|
"skills/code-review/**",
|
|
@@ -99,9 +95,8 @@
|
|
|
99
95
|
},
|
|
100
96
|
"dotnet": {
|
|
101
97
|
"label": "dotnet",
|
|
102
|
-
"description": "
|
|
98
|
+
"description": ".NET extras: tdd, knowledge-crunching + EF migration-guard hook.",
|
|
103
99
|
"locked": false,
|
|
104
|
-
"defaultChecked": false,
|
|
105
100
|
"deps": [],
|
|
106
101
|
"files": [
|
|
107
102
|
"skills/tdd/**",
|
|
@@ -121,9 +116,8 @@
|
|
|
121
116
|
},
|
|
122
117
|
"design-docs": {
|
|
123
118
|
"label": "design-docs",
|
|
124
|
-
"description": "
|
|
119
|
+
"description": "SessionStart hook that injects architecture/context-map/adr docs/ outputs into context.",
|
|
125
120
|
"locked": false,
|
|
126
|
-
"defaultChecked": false,
|
|
127
121
|
"deps": [],
|
|
128
122
|
"files": [
|
|
129
123
|
"hooks/inject-design-docs.cjs"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phuc1403/musketeer",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Distributable custom Claude Code harness — one declarative command scaffolds a curated company of musketeers (skills/agents/hooks) into any project's .claude/.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
package/src/muster.js
CHANGED
|
@@ -1,34 +1,67 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// The muster — the interactive picker. Uses @clack/prompts (ESM-only), loaded
|
|
4
|
-
// via dynamic import() for CommonJS interop.
|
|
5
|
-
//
|
|
4
|
+
// via dynamic import() for CommonJS interop. The selection rules live in the pure
|
|
5
|
+
// helpers below (planSelection / withLocked) so they can be unit-tested without a TTY.
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
+
* Compute the muster's option list and initial selection. Pure — no I/O.
|
|
9
|
+
*
|
|
10
|
+
* Rules:
|
|
11
|
+
* * Locked musketeers (core) are NOT selectable — always-on, hidden from the
|
|
12
|
+
* toggle list so they can't be unchecked. They're force-added by withLocked().
|
|
13
|
+
* * Every other musketeer defaults to UNSELECTED. Only musketeers already
|
|
14
|
+
* installed (present in the lockfile) start pre-checked, so re-running shows
|
|
15
|
+
* the current company and confirming unchanged is a no-op.
|
|
16
|
+
*
|
|
8
17
|
* @param {object} manifest
|
|
9
18
|
* @param {{musketeers:string[]}} prev current lockfile company
|
|
10
|
-
* @returns {
|
|
19
|
+
* @returns {{lockedIds:string[], options:Array<{value:string,label:string,hint:string}>, initialValues:string[]}}
|
|
11
20
|
*/
|
|
12
|
-
|
|
13
|
-
const clack = await import('@clack/prompts');
|
|
21
|
+
function planSelection(manifest, prev) {
|
|
14
22
|
const entries = Object.entries(manifest.musketeers);
|
|
15
|
-
const installed = new Set(prev.musketeers);
|
|
16
|
-
const hasLock = prev.musketeers.length > 0;
|
|
23
|
+
const installed = new Set((prev && prev.musketeers) || []);
|
|
17
24
|
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
.filter(([id, m]) => m.locked || (hasLock ? installed.has(id) : m.defaultChecked))
|
|
21
|
-
.map(([id]) => id);
|
|
25
|
+
const lockedIds = entries.filter(([, m]) => m.locked).map(([id]) => id);
|
|
26
|
+
const selectable = entries.filter(([, m]) => !m.locked);
|
|
22
27
|
|
|
23
|
-
const options =
|
|
28
|
+
const options = selectable.map(([id, m]) => ({
|
|
24
29
|
value: id,
|
|
25
|
-
label: m.
|
|
30
|
+
label: m.label,
|
|
26
31
|
hint: m.description,
|
|
27
32
|
}));
|
|
33
|
+
const initialValues = selectable
|
|
34
|
+
.filter(([id]) => installed.has(id))
|
|
35
|
+
.map(([id]) => id);
|
|
36
|
+
|
|
37
|
+
return { lockedIds, options, initialValues };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Finalize a raw multiselect result: dedupe and force every locked musketeer on
|
|
42
|
+
* (enforces locked-on — "core can't be removed").
|
|
43
|
+
* @param {string[]} selectedIds
|
|
44
|
+
* @param {string[]} lockedIds
|
|
45
|
+
* @returns {string[]}
|
|
46
|
+
*/
|
|
47
|
+
function withLocked(selectedIds, lockedIds) {
|
|
48
|
+
const chosen = new Set(Array.isArray(selectedIds) ? selectedIds : []);
|
|
49
|
+
for (const id of lockedIds || []) chosen.add(id);
|
|
50
|
+
return [...chosen];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {object} manifest
|
|
55
|
+
* @param {{musketeers:string[]}} prev current lockfile company
|
|
56
|
+
* @returns {Promise<?string[]>} chosen ids (incl. locked), or null if cancelled
|
|
57
|
+
*/
|
|
58
|
+
async function pick(manifest, prev) {
|
|
59
|
+
const clack = await import('@clack/prompts');
|
|
60
|
+
const { lockedIds, options, initialValues } = planSelection(manifest, prev);
|
|
28
61
|
|
|
29
62
|
clack.intro('musketeer — muster');
|
|
30
63
|
const selected = await clack.multiselect({
|
|
31
|
-
message: '
|
|
64
|
+
message: 'Pick your company — space toggles, enter confirms (core is always included):',
|
|
32
65
|
options,
|
|
33
66
|
initialValues,
|
|
34
67
|
required: false,
|
|
@@ -39,10 +72,9 @@ async function pick(manifest, prev) {
|
|
|
39
72
|
return null;
|
|
40
73
|
}
|
|
41
74
|
|
|
42
|
-
const chosen =
|
|
43
|
-
chosen.
|
|
44
|
-
|
|
45
|
-
return [...chosen];
|
|
75
|
+
const chosen = withLocked(selected, lockedIds);
|
|
76
|
+
clack.outro(`Company: ${chosen.join(', ')}`);
|
|
77
|
+
return chosen;
|
|
46
78
|
}
|
|
47
79
|
|
|
48
|
-
module.exports = { pick };
|
|
80
|
+
module.exports = { pick, planSelection, withLocked };
|
package/src/promote.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// `musketeer promote` — advance the installed CLI to the latest published rank
|
|
4
|
+
// (version). Detect-first: query the npm registry, compare, and only install when
|
|
5
|
+
// the running copy is behind. Distinct from `muster` (which picks the company in a
|
|
6
|
+
// project); promote upgrades the musketeer binary itself.
|
|
7
|
+
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
const pkg = require('../package.json');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Default command runner — same shape/semantics as the provisioner's runner.
|
|
13
|
+
* Captures stdout+stderr and never throws; tests inject their own `run`.
|
|
14
|
+
* @param {string} cmd
|
|
15
|
+
* @returns {{code:number, stdout:string, stderr:string}}
|
|
16
|
+
*/
|
|
17
|
+
function defaultRun(cmd) {
|
|
18
|
+
const r = spawnSync(cmd, { shell: true, encoding: 'utf8', windowsHide: true });
|
|
19
|
+
return {
|
|
20
|
+
code: typeof r.status === 'number' ? r.status : 1,
|
|
21
|
+
stdout: r.stdout || '',
|
|
22
|
+
stderr: r.stderr || '',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Compare dotted numeric versions: 1 if a>b, -1 if a<b, 0 if equal. */
|
|
27
|
+
function compareVersions(a, b) {
|
|
28
|
+
const pa = String(a).split('.').map(Number);
|
|
29
|
+
const pb = String(b).split('.').map(Number);
|
|
30
|
+
for (let i = 0; i < 3; i++) {
|
|
31
|
+
const x = pa[i] || 0;
|
|
32
|
+
const y = pb[i] || 0;
|
|
33
|
+
if (x > y) return 1;
|
|
34
|
+
if (x < y) return -1;
|
|
35
|
+
}
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Run the self-update. Detect latest from the registry, compare to the running
|
|
41
|
+
* version, install only when behind. Partial-failure tolerant — prints a manual
|
|
42
|
+
* fallback rather than throwing.
|
|
43
|
+
* @param {object} [overrides] { run, log, name, current } for hermetic tests
|
|
44
|
+
* @returns {Promise<{status:'up-to-date'|'promoted'|'failed'|'unknown', from:string, to:?string}>}
|
|
45
|
+
*/
|
|
46
|
+
async function run(overrides = {}) {
|
|
47
|
+
const exec = overrides.run || defaultRun;
|
|
48
|
+
const log = overrides.log || ((m) => process.stdout.write(m + '\n'));
|
|
49
|
+
const name = overrides.name || pkg.name;
|
|
50
|
+
const current = overrides.current || pkg.version;
|
|
51
|
+
|
|
52
|
+
log(`Checking rank… installed ${current} (${name})`);
|
|
53
|
+
const view = exec(`npm view ${name} version`);
|
|
54
|
+
const latest = (view.stdout || '').trim();
|
|
55
|
+
|
|
56
|
+
if (view.code !== 0 || !/^\d+\.\d+\.\d+/.test(latest)) {
|
|
57
|
+
log('Could not reach the npm registry to check for a newer version.');
|
|
58
|
+
log(`Try manually: npm i -g ${name}@latest`);
|
|
59
|
+
return { status: 'unknown', from: current, to: null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
log(` installed ${current} · latest ${latest}`);
|
|
63
|
+
if (compareVersions(latest, current) <= 0) {
|
|
64
|
+
log('Already at the latest rank — no promotion needed.');
|
|
65
|
+
return { status: 'up-to-date', from: current, to: latest };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
log(`→ npm i -g ${name}@latest`);
|
|
69
|
+
const ins = exec(`npm i -g ${name}@latest`);
|
|
70
|
+
if (ins.code !== 0) {
|
|
71
|
+
log('Promotion failed. Try manually (you may need elevated permissions):');
|
|
72
|
+
log(` npm i -g ${name}@latest`);
|
|
73
|
+
if (ins.stderr) log(ins.stderr.trim());
|
|
74
|
+
return { status: 'failed', from: current, to: latest };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
log(`Promoted: now at ${latest}.`);
|
|
78
|
+
return { status: 'promoted', from: current, to: latest };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { run, compareVersions, defaultRun };
|
package/src/reconcile.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { loadManifest, templateClaudeDir } = require('./manifest-loader');
|
|
4
|
-
const { resolveClosure
|
|
4
|
+
const { resolveClosure } = require('./resolver');
|
|
5
5
|
const { readLockfile, writeLockfile } = require('./lockfile');
|
|
6
6
|
const copier = require('./copier');
|
|
7
7
|
const settingsMerger = require('./settings-merger');
|
|
@@ -73,22 +73,4 @@ async function run(opts, overrides = {}) {
|
|
|
73
73
|
d.log('Done. Your .claude/ company is reconciled.');
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
* Print current company + all available musketeers (no UI).
|
|
78
|
-
*/
|
|
79
|
-
async function list(opts = {}, overrides = {}) {
|
|
80
|
-
const projectDir = opts.projectDir || process.cwd();
|
|
81
|
-
const d = { ...defaultDeps(), ...overrides };
|
|
82
|
-
const prev = readLockfile(projectDir);
|
|
83
|
-
const company = new Set(prev.musketeers.length ? prev.musketeers : []);
|
|
84
|
-
|
|
85
|
-
d.log(`Current company: ${prev.musketeers.length ? prev.musketeers.join(', ') : '(none installed yet)'}`);
|
|
86
|
-
d.log('\nAvailable musketeers:');
|
|
87
|
-
for (const [id, m] of Object.entries(d.manifest.musketeers)) {
|
|
88
|
-
const mark = company.has(id) || id === CORE_ID ? '[x]' : '[ ]';
|
|
89
|
-
const lock = m.locked ? ' (locked)' : m.defaultChecked ? '' : ' (opt-in)';
|
|
90
|
-
d.log(` ${mark} ${id}${lock} — ${m.description}`);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
module.exports = { run, list, defaultDeps };
|
|
76
|
+
module.exports = { run, defaultDeps };
|
package/src/schema.js
CHANGED
|
@@ -10,8 +10,7 @@
|
|
|
10
10
|
* "<id>": {
|
|
11
11
|
* label: string,
|
|
12
12
|
* description: string,
|
|
13
|
-
* locked: boolean,
|
|
14
|
-
* defaultChecked: boolean,
|
|
13
|
+
* locked: boolean, // always-on; hidden from the muster toggle list
|
|
15
14
|
* files: string[], // globs relative to template/.claude/
|
|
16
15
|
* deps: string[], // other musketeer ids (intra-package edges)
|
|
17
16
|
* settings: SettingFragment[],
|
|
@@ -80,7 +79,6 @@ function validateMusketeer(id, m) {
|
|
|
80
79
|
if (typeof m.label !== 'string') fail(`${ctx}.label must be a string`);
|
|
81
80
|
if (typeof m.description !== 'string') fail(`${ctx}.description must be a string`);
|
|
82
81
|
if (typeof m.locked !== 'boolean') fail(`${ctx}.locked must be a boolean`);
|
|
83
|
-
if (typeof m.defaultChecked !== 'boolean') fail(`${ctx}.defaultChecked must be a boolean`);
|
|
84
82
|
if (!isStringArray(m.files)) fail(`${ctx}.files must be a string[]`);
|
|
85
83
|
if (!isStringArray(m.deps)) fail(`${ctx}.deps must be a string[]`);
|
|
86
84
|
if (!isStringArray(m.prereqs)) fail(`${ctx}.prereqs must be a string[]`);
|