@lovrozagar/grunt 0.9.5 → 0.10.0

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/cli/grunt.test.ts CHANGED
@@ -14,7 +14,9 @@ const detectPackageManager = vi.hoisted(() => vi.fn());
14
14
  const actualDetect = vi.hoisted(() => ({ fn: null as null | ((...args: unknown[]) => unknown) }));
15
15
  const isInteractive = vi.hoisted(() => vi.fn(() => false));
16
16
  const select = vi.hoisted(() => vi.fn());
17
+ const multiselect = vi.hoisted(() => vi.fn(async () => []));
17
18
  const confirm = vi.hoisted(() => vi.fn());
19
+ const selectedPacks = vi.hoisted(() => vi.fn(() => [] as string[]));
18
20
  const spinner = vi.hoisted(() =>
19
21
  vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
20
22
  );
@@ -46,10 +48,15 @@ vi.mock("../scripts/package-manager.mjs", async (importOriginal) => {
46
48
  vi.mock("./prompt.mjs", () => ({
47
49
  isInteractive,
48
50
  select,
51
+ multiselect,
49
52
  confirm,
50
53
  spinner,
51
54
  bailIfCancel,
52
55
  }));
56
+ vi.mock("../scripts/feature-packs.mjs", async (importOriginal) => {
57
+ const actual = await importOriginal<typeof import("../scripts/feature-packs.mjs")>();
58
+ return { ...actual, selectedPacks };
59
+ });
53
60
 
54
61
  import {
55
62
  PACKAGE_MANAGER_ASK,
@@ -68,7 +75,7 @@ Commands:
68
75
  sync-globals run grunt:sync:globals (dry-run; --apply to write)
69
76
  purge-mcps run grunt:purge:global-mcps (dry-run; --apply to write)
70
77
  doctor run grunt:doctor
71
- setup run grunt:setup — handheld keys/OAuth (speak, listen, google-workspace, browser)
78
+ setup run grunt:setup — handheld keys/OAuth (speak, listen, google-workspace, browser, jev)
72
79
  upgrade Self-update to the latest grunt, then re-init: copy owned files, prune retired names, print reserved skills
73
80
  map [dir] Code-only folder map (git-tracked, no files); dir for depth
74
81
  help Show this help
@@ -82,6 +89,7 @@ Flags:
82
89
  --apply Write for sync-globals / purge-mcps
83
90
  --host <id> sync-globals host
84
91
  --pm <name> npm | yarn | pnpm | bun (else lockfile, then how grunt was launched, then ask)
92
+ --packs <list> Optional packs: google-workspace,listen,speak,clasp or none
85
93
  `;
86
94
 
87
95
  const pkgRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -125,6 +133,10 @@ describe("start", () => {
125
133
  spinner.mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() }));
126
134
  bailIfCancel.mockReset();
127
135
  bailIfCancel.mockImplementation((v) => v);
136
+ multiselect.mockReset();
137
+ multiselect.mockResolvedValue([]);
138
+ selectedPacks.mockReset();
139
+ selectedPacks.mockReturnValue([]);
128
140
  });
129
141
 
130
142
  afterEach(() => {
@@ -659,6 +671,46 @@ describe("parseArgv --pm", () => {
659
671
  });
660
672
  });
661
673
 
674
+ it("TTY upgrade asks packs only", async () => {
675
+ isInteractive.mockReturnValue(true);
676
+ confirm.mockClear();
677
+ multiselect.mockClear();
678
+ init.mockClear();
679
+ multiselect.mockResolvedValue(["listen", "speak"]);
680
+ process.argv = ["node", "grunt", "upgrade", "--no-self-update"];
681
+ await start();
682
+ expect(confirm).not.toHaveBeenCalled();
683
+ expect(multiselect).toHaveBeenCalledWith(
684
+ expect.objectContaining({ message: "Optional packs", required: false, initialValues: [] }),
685
+ );
686
+ expect(init).toHaveBeenCalledWith(process.cwd(), {
687
+ skipGlobals: false,
688
+ packageManager: "npm",
689
+ packs: ["listen", "speak"],
690
+ });
691
+ });
692
+
693
+ it("TTY init pre-checks a saved features file and skips the pack prompt when --packs is set", async () => {
694
+ isInteractive.mockReturnValue(true);
695
+ selectedPacks.mockReturnValue(["speak"]);
696
+ confirm.mockResolvedValue(true);
697
+ process.argv = ["node", "grunt", "init"];
698
+ await start();
699
+ expect(multiselect).toHaveBeenCalledWith(
700
+ expect.objectContaining({ initialValues: ["speak"], required: false }),
701
+ );
702
+ multiselect.mockClear();
703
+ confirm.mockClear();
704
+ init.mockClear();
705
+ process.argv = ["node", "grunt", "init", "--packs", "listen"];
706
+ await start();
707
+ expect(multiselect).not.toHaveBeenCalled();
708
+ expect(init).toHaveBeenCalledWith(
709
+ process.cwd(),
710
+ expect.objectContaining({ packs: ["listen"], applyGlobals: true }),
711
+ );
712
+ });
713
+
662
714
  it("missing, empty, flag, and unknown are pmError", () => {
663
715
  expect(parseArgv(["generate", "--pm"])).toMatchObject({ pmError: true });
664
716
  expect(parseArgv(["generate", "--pm="])).toMatchObject({ pmError: true });
@@ -666,3 +718,72 @@ describe("parseArgv --pm", () => {
666
718
  expect(parseArgv(["generate", "--pm", "deno"])).toMatchObject({ pmError: true });
667
719
  });
668
720
  });
721
+
722
+ describe("parseArgv --packs", () => {
723
+ it("pair, equals, and none", () => {
724
+ expect(parseArgv(["init", "--packs", "speak,listen"])).toMatchObject({
725
+ cmd: "init",
726
+ packs: ["listen", "speak"],
727
+ packsError: false,
728
+ });
729
+ expect(parseArgv(["init", "--packs=google-workspace,clasp"])).toMatchObject({
730
+ packs: ["google-workspace", "clasp"],
731
+ packsError: false,
732
+ });
733
+ expect(parseArgv(["init", "--packs", "none"])).toMatchObject({ packs: [], packsError: false });
734
+ expect(parseArgv(["init", "--packs="])).toMatchObject({ packs: [], packsError: false });
735
+ });
736
+
737
+ it("missing, flag, and unknown are packsError", () => {
738
+ expect(parseArgv(["init", "--packs"])).toMatchObject({ packsError: true });
739
+ expect(parseArgv(["init", "--packs", "--yes"])).toMatchObject({ packsError: true });
740
+ expect(parseArgv(["init", "--packs", "nope"])).toMatchObject({ packsError: true });
741
+ expect(parseArgv(["init", "--packs=nope"])).toMatchObject({ packsError: true });
742
+ });
743
+
744
+ it("unknown --packs writes usage", async () => {
745
+ const lines: string[] = [];
746
+ const stdoutWrite = process.stdout.write;
747
+ const exitCode = process.exitCode;
748
+ const argv = process.argv.slice();
749
+ process.stdout.write = ((buf: string | Uint8Array) => {
750
+ lines.push(String(buf));
751
+ return true;
752
+ }) as typeof process.stdout.write;
753
+ process.exitCode = 0;
754
+ process.argv = ["node", "grunt", "init", "--packs", "nope"];
755
+ init.mockClear();
756
+ try {
757
+ await start();
758
+ expect(lines.join("")).toBe(USAGE);
759
+ expect(process.exitCode).toBe(1);
760
+ expect(init).not.toHaveBeenCalled();
761
+ } finally {
762
+ process.stdout.write = stdoutWrite;
763
+ process.exitCode = exitCode;
764
+ process.argv = argv;
765
+ }
766
+ });
767
+
768
+ it("TTY upgrade with --packs skips the pack prompt", async () => {
769
+ isInteractive.mockReturnValue(true);
770
+ multiselect.mockClear();
771
+ confirm.mockClear();
772
+ init.mockClear();
773
+ process.argv = ["node", "grunt", "upgrade", "--no-self-update", "--packs", "listen"];
774
+ const stdoutWrite = process.stdout.write;
775
+ process.stdout.write = (() => true) as typeof process.stdout.write;
776
+ try {
777
+ await start();
778
+ expect(multiselect).not.toHaveBeenCalled();
779
+ expect(confirm).not.toHaveBeenCalled();
780
+ expect(init).toHaveBeenCalledWith(process.cwd(), {
781
+ skipGlobals: false,
782
+ packageManager: "npm",
783
+ packs: ["listen"],
784
+ });
785
+ } finally {
786
+ process.stdout.write = stdoutWrite;
787
+ }
788
+ });
789
+ });
package/cli/init.mjs CHANGED
@@ -31,6 +31,14 @@ import {
31
31
  runScriptArgs,
32
32
  } from "../scripts/package-manager.mjs"
33
33
  import { LAW_REL, emitMaps } from "../scripts/emit-maps.mjs"
34
+ import {
35
+ isUnselectedPackPath,
36
+ resolvePackSelection,
37
+ unselectedPackRels,
38
+ unselectedRefNames,
39
+ unselectedScriptNames,
40
+ writePacks,
41
+ } from "../scripts/feature-packs.mjs"
34
42
 
35
43
  export {
36
44
  GUARDED_ROOT_FILES,
@@ -78,9 +86,12 @@ export const PRODUCT_SCRIPTS = [
78
86
  "scrub-text-lib.mjs",
79
87
  "session-map.mjs",
80
88
  "sync-global-settings.mjs",
89
+ "board.mjs",
81
90
  "browser.mjs",
91
+ "feature-packs.mjs",
82
92
  "speak.mjs",
83
93
  "listen.mjs",
94
+ "jev.mjs",
84
95
  "google-workspace.mjs",
85
96
  "interactive.mjs",
86
97
  "prompt.mjs",
@@ -120,6 +131,7 @@ export const RESERVED_SKILLS = [
120
131
  "google-workspace",
121
132
  "handoff",
122
133
  "implement-plan",
134
+ "jev",
123
135
  "listen",
124
136
  "pickup",
125
137
  "speak",
@@ -388,10 +400,11 @@ export function snapshotReferenceDocs(dest) {
388
400
  return snap
389
401
  }
390
402
 
391
- export function remergeReferenceDocs(dest, snap, pkgRoot) {
403
+ export function remergeReferenceDocs(dest, snap, pkgRoot, skip = new Set()) {
392
404
  const pkgDir = path.join(pkgRoot, REFERENCE_REL)
393
405
  const destDir = path.join(dest, REFERENCE_REL)
394
406
  for (const [name, existing] of Object.entries(snap)) {
407
+ if (skip.has(name)) continue
395
408
  const pkgFile = path.join(pkgDir, name)
396
409
  if (!fs.existsSync(pkgFile)) continue
397
410
  const incoming = fs.readFileSync(pkgFile, "utf8")
@@ -555,7 +568,7 @@ export function resolveInitPackageManager(dest, { packageManager, env = process.
555
568
  return detected.manager
556
569
  }
557
570
 
558
- export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileSync, skipGlobals = false, applyGlobals, onPhase, packageManager, env = process.env } = {}) {
571
+ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileSync, skipGlobals = false, applyGlobals, onPhase, packageManager, env = process.env, packs } = {}) {
559
572
  dest = path.resolve(dest)
560
573
  const pkgRoot = path.resolve(pkgRootOpt ?? PKG_ROOT)
561
574
  const skipGlobalsApply =
@@ -571,6 +584,9 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
571
584
  }
572
585
 
573
586
  const self = samePath(dest, pkgRoot)
587
+ const selection = self ? { packs: [], answered: false } : resolvePackSelection(dest, packs)
588
+ const selected = new Set(selection.packs)
589
+ const allowPack = (src) => !isUnselectedPackPath(path.relative(pkgRoot, src), selected)
574
590
 
575
591
  phase("merge", () => {
576
592
  const wsSkills = path.join(dest, WORKSPACE_SKILLS_REL)
@@ -597,12 +613,12 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
597
613
  fs.mkdirSync(d, { recursive: true })
598
614
  if (samePath(src, d)) continue
599
615
  if (dir === ".claude") {
600
- copyTree(src, d, (s) => path.basename(s) !== "settings.json")
616
+ copyTree(src, d, (s) => path.basename(s) !== "settings.json" && allowPack(s))
601
617
  mergeClaudeSettings(dest, pkgRoot)
602
618
  } else if (dir === ".rulesync") {
603
- copyTree(src, d, (s) => !GENERATED_MAP_FILES.has(path.basename(s)))
619
+ copyTree(src, d, (s) => !GENERATED_MAP_FILES.has(path.basename(s)) && allowPack(s))
604
620
  } else {
605
- copyTree(src, d)
621
+ copyTree(src, d, allowPack)
606
622
  }
607
623
  }
608
624
 
@@ -613,7 +629,9 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
613
629
  }
614
630
 
615
631
  fs.mkdirSync(path.join(dest, "scripts"), { recursive: true })
632
+ const skipScripts = unselectedScriptNames(selected)
616
633
  for (const name of PRODUCT_SCRIPTS) {
634
+ if (skipScripts.has(name)) continue
617
635
  const src = path.join(pkgRoot, "scripts", name)
618
636
  const d = path.join(dest, "scripts", name)
619
637
  if (samePath(src, d)) continue
@@ -624,10 +642,14 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
624
642
  mergeGitignore(dest)
625
643
 
626
644
  if (!self) {
627
- remergeReferenceDocs(dest, refSnap, pkgRoot)
645
+ remergeReferenceDocs(dest, refSnap, pkgRoot, unselectedRefNames(selected))
628
646
  seedPipelineConfig(dest, destPipelineSrc)
629
647
  mergePackageJson(dest, pkgRoot)
630
648
  copyRootIfMissing(dest, pkgRoot)
649
+ for (const rel of unselectedPackRels(selected)) {
650
+ fs.rmSync(path.join(dest, rel), { recursive: true, force: true })
651
+ }
652
+ if (selection.answered) writePacks(dest, selection.packs)
631
653
  emitDestMaps(dest)
632
654
  }
633
655
  })
package/cli/init.test.ts CHANGED
@@ -1925,7 +1925,8 @@ describe("init", () => {
1925
1925
  expect(fs.existsSync(path.join(dest, ".mcp.json"))).toBe(false);
1926
1926
 
1927
1927
  for (const name of PRODUCT_FILES) {
1928
- expect(fs.statSync(path.join(dest, "scripts", name)).isFile()).toBe(true);
1928
+ const optional = ["speak.mjs", "listen.mjs", "google-workspace.mjs"].includes(name);
1929
+ expect(fs.existsSync(path.join(dest, "scripts", name)), name).toBe(!optional);
1929
1930
  }
1930
1931
  expect(fs.statSync(path.join(dest, "scripts", "scrub-text")).isDirectory()).toBe(true);
1931
1932
  expect(fs.readFileSync(path.join(dest, "scripts", "scrub-text", "inside"), "utf8")).toBe(
@@ -2036,6 +2037,7 @@ describe("init", () => {
2036
2037
  "google-workspace",
2037
2038
  "handoff",
2038
2039
  "implement-plan",
2040
+ "jev",
2039
2041
  "listen",
2040
2042
  "pickup",
2041
2043
  "speak",
@@ -2119,9 +2121,13 @@ describe("init", () => {
2119
2121
  expect(claude.split("<!-- grunt:end -->")).toHaveLength(2);
2120
2122
  expect(fs.existsSync(path.join(dest, "scripts", "scrub-text"))).toBe(true);
2121
2123
  expect(fs.existsSync(path.join(dest, "scripts", "grunt-config.mjs"))).toBe(false);
2122
- expect(fs.existsSync(path.join(dest, "scripts", "speak.mjs"))).toBe(true);
2123
- expect(fs.existsSync(path.join(dest, "scripts", "listen.mjs"))).toBe(true);
2124
- expect(fs.existsSync(path.join(dest, "scripts", "google-workspace.mjs"))).toBe(true);
2124
+ expect(fs.existsSync(path.join(dest, "scripts", "speak.mjs"))).toBe(false);
2125
+ expect(fs.existsSync(path.join(dest, "scripts", "listen.mjs"))).toBe(false);
2126
+ expect(fs.existsSync(path.join(dest, "scripts", "google-workspace.mjs"))).toBe(false);
2127
+ expect(fs.existsSync(path.join(dest, ".rulesync", "skills", "clasp"))).toBe(false);
2128
+ expect(fs.existsSync(path.join(dest, ".rulesync", "skills", "browser"))).toBe(true);
2129
+ expect(fs.existsSync(path.join(dest, ".rulesync", "reference", "listen.md"))).toBe(false);
2130
+ expect(fs.existsSync(path.join(dest, ".rulesync", "grunt.features.jsonc"))).toBe(false);
2125
2131
  expect(fs.existsSync(path.join(dest, ".claude", "settings.json"))).toBe(true);
2126
2132
  expect(fs.existsSync(path.join(dest, ".rulesync", "grunt.config.jsonc"))).toBe(false);
2127
2133
  expect(exec.mock.calls.map((c) => c[1])).toEqual([
@@ -2132,6 +2138,84 @@ describe("init", () => {
2132
2138
  ]);
2133
2139
  });
2134
2140
 
2141
+ function writeSkill(root: string, name: string) {
2142
+ for (const dir of [".rulesync/skills", ".grok/skills", ".claude/skills", ".agents/skills"]) {
2143
+ fs.mkdirSync(path.join(root, dir, name), { recursive: true });
2144
+ fs.writeFileSync(path.join(root, dir, name, "SKILL.md"), `${name}\n`);
2145
+ }
2146
+ }
2147
+
2148
+ it("copies only selected packs and drops an unselected ref instead of merging it back", () => {
2149
+ const pkgRoot = stubPkgRoot();
2150
+ writeSkill(pkgRoot, "ask");
2151
+ writeSkill(pkgRoot, "listen");
2152
+ writeSkill(pkgRoot, "speak");
2153
+ writeSkill(pkgRoot, "clasp");
2154
+ fs.mkdirSync(path.join(pkgRoot, ".rulesync", "reference"), { recursive: true });
2155
+ fs.writeFileSync(path.join(pkgRoot, ".rulesync", "reference", "listen.md"), "# Listen\n\npack\n");
2156
+ fs.writeFileSync(path.join(pkgRoot, ".rulesync", "reference", "law.md"), "# Law\n\n## Skills naming\n\nstock\n");
2157
+ const dest = tmp("packs-default-");
2158
+ fs.mkdirSync(path.join(dest, ".rulesync", "reference"), { recursive: true });
2159
+ fs.writeFileSync(
2160
+ path.join(dest, ".rulesync", "reference", "listen.md"),
2161
+ "# Listen\n\npack\n\n## Mine\n\nkeep\n",
2162
+ );
2163
+ fs.writeFileSync(
2164
+ path.join(dest, ".rulesync", "reference", "law.md"),
2165
+ "# Law\n\n## Skills naming\n\nold\n\n## Mine\n\nkeep\n",
2166
+ );
2167
+ fs.mkdirSync(path.join(dest, ".grok", "skills", "clasp"), { recursive: true });
2168
+ fs.writeFileSync(path.join(dest, ".grok", "skills", "clasp", "SKILL.md"), "old clasp\n");
2169
+ const exec = vi.fn();
2170
+ init(dest, { pkgRoot, execFileSync: exec, packageManager: "npm" });
2171
+ expect(fs.readFileSync(path.join(dest, ".rulesync", "skills", "ask", "SKILL.md"), "utf8")).toBe("ask\n");
2172
+ expect(fs.existsSync(path.join(dest, ".rulesync", "skills", "listen"))).toBe(false);
2173
+ expect(fs.existsSync(path.join(dest, ".grok", "skills", "speak"))).toBe(false);
2174
+ expect(fs.existsSync(path.join(dest, ".claude", "skills", "clasp"))).toBe(false);
2175
+ expect(fs.existsSync(path.join(dest, ".agents", "skills", "listen"))).toBe(false);
2176
+ expect(fs.existsSync(path.join(dest, ".grok", "skills", "clasp"))).toBe(false);
2177
+ expect(fs.existsSync(path.join(dest, "scripts", "listen.mjs"))).toBe(false);
2178
+ expect(fs.existsSync(path.join(dest, "scripts", "speak.mjs"))).toBe(false);
2179
+ expect(fs.existsSync(path.join(dest, ".rulesync", "reference", "listen.md"))).toBe(false);
2180
+ expect(fs.readFileSync(path.join(dest, ".rulesync", "reference", "law.md"), "utf8")).toContain("## Mine");
2181
+ expect(fs.existsSync(path.join(dest, ".rulesync", "grunt.features.jsonc"))).toBe(false);
2182
+
2183
+ const picked = tmp("packs-listen-");
2184
+ init(picked, { pkgRoot, execFileSync: exec, packageManager: "npm", packs: ["listen"] });
2185
+ expect(fs.readFileSync(path.join(picked, ".rulesync", "skills", "listen", "SKILL.md"), "utf8")).toBe("listen\n");
2186
+ expect(fs.readFileSync(path.join(picked, "scripts", "listen.mjs"), "utf8")).toBe("listen.mjs");
2187
+ expect(fs.existsSync(path.join(picked, ".rulesync", "reference", "listen.md"))).toBe(true);
2188
+ expect(fs.existsSync(path.join(picked, ".rulesync", "skills", "speak"))).toBe(false);
2189
+ expect(fs.existsSync(path.join(picked, "scripts", "speak.mjs"))).toBe(false);
2190
+ expect(JSON.parse(fs.readFileSync(path.join(picked, ".rulesync", "grunt.features.jsonc"), "utf8"))).toEqual({
2191
+ packs: ["listen"],
2192
+ });
2193
+
2194
+ const saved = tmp("packs-saved-");
2195
+ fs.mkdirSync(path.join(saved, ".rulesync"), { recursive: true });
2196
+ fs.writeFileSync(
2197
+ path.join(saved, ".rulesync", "grunt.features.jsonc"),
2198
+ '{"packs":["speak"]}\n',
2199
+ );
2200
+ init(saved, { pkgRoot, execFileSync: exec, packageManager: "npm" });
2201
+ expect(fs.existsSync(path.join(saved, ".rulesync", "skills", "speak"))).toBe(true);
2202
+ expect(fs.existsSync(path.join(saved, "scripts", "listen.mjs"))).toBe(false);
2203
+ expect(fs.readFileSync(path.join(saved, ".rulesync", "grunt.features.jsonc"), "utf8")).toBe(
2204
+ '{"packs":["speak"]}\n',
2205
+ );
2206
+
2207
+ const pkgSelf = stubPkgRoot();
2208
+ writeSkill(pkgSelf, "clasp");
2209
+ fs.writeFileSync(path.join(pkgSelf, "scripts", "speak.mjs"), "keep-speak\n");
2210
+ init(pkgSelf, { pkgRoot: pkgSelf, execFileSync: exec, packs: [] });
2211
+ expect(fs.readFileSync(path.join(pkgSelf, ".rulesync", "skills", "clasp", "SKILL.md"), "utf8")).toBe("clasp\n");
2212
+ expect(fs.readFileSync(path.join(pkgSelf, "scripts", "speak.mjs"), "utf8")).toBe("keep-speak\n");
2213
+ expect(fs.existsSync(path.join(pkgSelf, ".rulesync", "grunt.features.jsonc"))).toBe(false);
2214
+ expect(() => init(tmp("packs-bad-"), { pkgRoot, execFileSync: exec, packageManager: "npm", packs: ["nope"] })).toThrow(
2215
+ /unknown pack/,
2216
+ );
2217
+ });
2218
+
2135
2219
  it("--skip-globals skips sync:globals:apply", () => {
2136
2220
  const pkgRoot = stubPkgRoot();
2137
2221
  const dest = tmp("grunt-skip-globals-");
@@ -2611,16 +2695,31 @@ describe("product scripts are consumer-safe", () => {
2611
2695
  expect(fs.existsSync(path.join(dest, "cli"))).toBe(false);
2612
2696
  expect(fs.existsSync(path.join(dest, "scripts", "guarded-roots.mjs"))).toBe(true);
2613
2697
 
2698
+ const optionalScripts = new Set(["speak.mjs", "listen.mjs", "google-workspace.mjs"]);
2614
2699
  for (const name of PRODUCT_FILES) {
2615
2700
  const file = path.join(dest, "scripts", name);
2701
+ if (optionalScripts.has(name)) {
2702
+ expect(fs.existsSync(file), name).toBe(false);
2703
+ continue;
2704
+ }
2616
2705
  expect(fs.existsSync(file), name).toBe(true);
2617
2706
  const text = fs.readFileSync(file, "utf8");
2618
2707
  for (const spec of relativeSpecs(text, RELATIVE_IMPORT_RE)) {
2619
2708
  const resolved = path.resolve(path.dirname(file), spec);
2620
- expect(fs.existsSync(resolved), `${name} -> ${spec}`).toBe(true);
2709
+ const optional = optionalScripts.has(path.basename(resolved));
2710
+ expect(fs.existsSync(resolved), `${name} -> ${spec}`).toBe(!optional);
2621
2711
  }
2622
2712
  }
2623
2713
 
2714
+ const setupRun = spawnDest(dest, ["./scripts/setup.mjs"], {
2715
+ ...process.env,
2716
+ CI: "1",
2717
+ PATH: ["/usr/bin", "/bin"].join(path.delimiter),
2718
+ });
2719
+ expect(`${setupRun.stdout}${setupRun.stderr}`).not.toMatch(/ERR_MODULE_NOT_FOUND/);
2720
+ expect(setupRun.status).toBe(1);
2721
+ expect(`${setupRun.stdout}${setupRun.stderr}`).toContain("usage: setup");
2722
+
2624
2723
  const loaded = spawnDest(dest, ["./scripts/guarded-roots.mjs"]);
2625
2724
  expect(loaded.status).toBe(1);
2626
2725
  expect(loaded.stderr).toBe("usage: guarded-roots.mjs generate|check|watch\n");
package/cli/prompt.mjs CHANGED
@@ -2,6 +2,7 @@ export {
2
2
  bailIfCancel,
3
3
  confirm,
4
4
  isInteractive,
5
+ multiselect,
5
6
  password,
6
7
  select,
7
8
  spinner,
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
 
3
3
  const clackSelect = vi.hoisted(() => vi.fn());
4
+ const clackMultiselect = vi.hoisted(() => vi.fn());
4
5
  const clackConfirm = vi.hoisted(() => vi.fn());
5
6
  const clackText = vi.hoisted(() => vi.fn());
6
7
  const clackPassword = vi.hoisted(() => vi.fn());
@@ -10,6 +11,7 @@ const clackCancel = vi.hoisted(() => vi.fn());
10
11
 
11
12
  vi.mock("@clack/prompts", () => ({
12
13
  select: clackSelect,
14
+ multiselect: clackMultiselect,
13
15
  confirm: clackConfirm,
14
16
  text: clackText,
15
17
  password: clackPassword,
@@ -23,6 +25,7 @@ import {
23
25
  confirm,
24
26
  isInteractive,
25
27
  password,
28
+ multiselect,
26
29
  select,
27
30
  spinner,
28
31
  text,
@@ -164,12 +167,38 @@ describe("bailIfCancel", () => {
164
167
  describe("clack wrappers", () => {
165
168
  afterEach(() => {
166
169
  clackSelect.mockReset();
170
+ clackMultiselect.mockReset();
167
171
  clackConfirm.mockReset();
168
172
  clackSpinner.mockReset();
169
173
  clackIsCancel.mockReset();
170
174
  clackCancel.mockReset();
171
175
  });
172
176
 
177
+ it("multiselect returns the chosen packs", async () => {
178
+ clackMultiselect.mockResolvedValue(["listen"]);
179
+ clackIsCancel.mockReturnValue(false);
180
+ await expect(
181
+ multiselect({ message: "Optional packs", options: [], required: false }),
182
+ ).resolves.toEqual(["listen"]);
183
+ expect(clackMultiselect).toHaveBeenCalledWith({
184
+ message: "Optional packs",
185
+ options: [],
186
+ required: false,
187
+ });
188
+ });
189
+
190
+ it("multiselect cancel exits 0", async () => {
191
+ clackMultiselect.mockResolvedValue(Symbol("cancel"));
192
+ clackIsCancel.mockReturnValue(true);
193
+ const exit = vi.spyOn(process, "exit").mockImplementation((() => {
194
+ throw new Error("EXIT");
195
+ }) as typeof process.exit);
196
+ await expect(multiselect({})).rejects.toThrow("EXIT");
197
+ expect(clackCancel).toHaveBeenCalledWith("Aborted");
198
+ expect(exit).toHaveBeenCalledWith(0);
199
+ exit.mockRestore();
200
+ });
201
+
173
202
  it("select bails then returns", async () => {
174
203
  clackSelect.mockResolvedValue("init");
175
204
  clackIsCancel.mockReturnValue(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovrozagar/grunt",
3
- "version": "0.9.5",
3
+ "version": "0.10.0",
4
4
  "description": "Multi-agent orchestrator SoT — deep-merge rulesync trees, product scripts, and generate pipeline into a consumer repo",
5
5
  "keywords": [
6
6
  "agents",
@@ -48,9 +48,12 @@
48
48
  "scripts/scrub-text-lib.mjs",
49
49
  "scripts/session-map.mjs",
50
50
  "scripts/sync-global-settings.mjs",
51
+ "scripts/board.mjs",
51
52
  "scripts/browser.mjs",
53
+ "scripts/feature-packs.mjs",
52
54
  "scripts/speak.mjs",
53
55
  "scripts/listen.mjs",
56
+ "scripts/jev.mjs",
54
57
  "scripts/google-workspace.mjs",
55
58
  "scripts/interactive.mjs",
56
59
  "scripts/prompt.mjs",