@lovrozagar/grunt 0.9.4 → 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/init.test.ts CHANGED
@@ -37,6 +37,14 @@ import {
37
37
  RETIRED_PATHS,
38
38
  RESERVED_SKILLS,
39
39
  LAUNCH_SCRIPTS,
40
+ HISTORICAL_SCRIPT_DEFAULTS,
41
+ E2E_VITEST_CONFIGS,
42
+ extraH2Sections,
43
+ mergeReferenceMarkdown,
44
+ snapshotReferenceDocs,
45
+ remergeReferenceDocs,
46
+ seedPipelineConfig,
47
+ PIPELINE_CONFIG_REL,
40
48
  mergeClaudeSettings,
41
49
  mergeGitignore,
42
50
  mergeGuardedContent,
@@ -55,6 +63,7 @@ import {
55
63
  } from "./init.mjs";
56
64
  import { UNKNOWN_PACKAGE_MANAGER } from "../scripts/package-manager.mjs";
57
65
  import { runGuardedRoots } from "../scripts/guarded-roots.mjs";
66
+ import { COMMANDS as PIPELINE_COMMANDS } from "../scripts/pipeline.mjs";
58
67
 
59
68
  const tmpDirs: string[] = [];
60
69
  afterEach(() => {
@@ -1714,6 +1723,158 @@ describe("mergePackageJson", () => {
1714
1723
  expect(pkg.scripts[k]).toBe(v);
1715
1724
  }
1716
1725
  });
1726
+
1727
+ it("keeps customized grunt launchers that are not a known default", () => {
1728
+ const pkgRoot = stubPkgRoot();
1729
+ const dest = tmp("pj-keep-custom-launch-");
1730
+ const wrapped = {
1731
+ "grunt:antigravity": "node scripts/with-env.mjs agy --dangerously-skip-permissions",
1732
+ "grunt:claude": "node scripts/with-env.mjs claude --dangerously-skip-permissions",
1733
+ "grunt:codex": "node scripts/with-env.mjs codex --dangerously-bypass-approvals-and-sandbox",
1734
+ "grunt:gemini": "node scripts/with-env.mjs gemini --yolo",
1735
+ "grunt:grok": "node scripts/with-env.mjs grok --yolo",
1736
+ };
1737
+ fs.writeFileSync(path.join(dest, "package.json"), JSON.stringify({ scripts: wrapped }));
1738
+ mergePackageJson(dest, pkgRoot);
1739
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1740
+ expect(out.scripts["grunt:antigravity"]).toBe(wrapped["grunt:antigravity"]);
1741
+ expect(out.scripts["grunt:claude"]).toBe(wrapped["grunt:claude"]);
1742
+ expect(out.scripts["grunt:codex"]).toBe(wrapped["grunt:codex"]);
1743
+ expect(out.scripts["grunt:gemini"]).toBe(wrapped["grunt:gemini"]);
1744
+ expect(out.scripts["grunt:grok"]).toBe(wrapped["grunt:grok"]);
1745
+ });
1746
+
1747
+ it("migrates a historical bare launcher to the current default", () => {
1748
+ const pkgRoot = stubPkgRoot();
1749
+ const dest = tmp("pj-hist-launch-");
1750
+ fs.writeFileSync(
1751
+ path.join(dest, "package.json"),
1752
+ JSON.stringify({ scripts: { "grunt:antigravity": "antigravity" } }),
1753
+ );
1754
+ mergePackageJson(dest, pkgRoot);
1755
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1756
+ expect(out.scripts["grunt:antigravity"]).toBe(LAUNCH_SCRIPTS.antigravity);
1757
+ expect(HISTORICAL_SCRIPT_DEFAULTS.antigravity).toContain("antigravity");
1758
+ });
1759
+
1760
+ it("does not add grunt:test:e2e when dest has no e2e vitest config", () => {
1761
+ const dest = tmp("pj-noe2e-");
1762
+ fs.writeFileSync(path.join(dest, "package.json"), JSON.stringify({ name: "app", scripts: {} }));
1763
+ mergePackageJson(dest, PKG_ROOT);
1764
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1765
+ expect(out.scripts["grunt:test:e2e"]).toBeUndefined();
1766
+ expect(out.scripts["grunt:rulesync:check"]).toBe("node ./scripts/guarded-roots.mjs check");
1767
+ });
1768
+
1769
+ it("adds grunt:test:e2e when dest has a vitest e2e config", () => {
1770
+ const dest = tmp("pj-e2e-");
1771
+ fs.writeFileSync(path.join(dest, "package.json"), JSON.stringify({ name: "app", scripts: {} }));
1772
+ fs.writeFileSync(path.join(dest, E2E_VITEST_CONFIGS[0]), "export default {}\n");
1773
+ mergePackageJson(dest, PKG_ROOT);
1774
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1775
+ expect(out.scripts["grunt:test:e2e"]).toBe("vitest run -c vitest.e2e.config.ts");
1776
+ });
1777
+
1778
+ it("keeps an existing grunt:test:e2e even without an e2e config", () => {
1779
+ const dest = tmp("pj-e2e-keep-");
1780
+ fs.writeFileSync(
1781
+ path.join(dest, "package.json"),
1782
+ JSON.stringify({ scripts: { "grunt:test:e2e": "node scripts/e2e.mjs" } }),
1783
+ );
1784
+ mergePackageJson(dest, PKG_ROOT);
1785
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1786
+ expect(out.scripts["grunt:test:e2e"]).toBe("node scripts/e2e.mjs");
1787
+ });
1788
+ });
1789
+
1790
+ describe("reference markdown merge", () => {
1791
+ it("extraH2Sections keeps titles incoming does not have, including CRLF", () => {
1792
+ const existing = "# Law\r\n\r\n## Skills naming\r\n\r\nstock\r\n\r\n## Autorun\r\n\r\ngo-live\r\n";
1793
+ const incoming = "# Law\n\n## Skills naming\n\nstock plus su\n";
1794
+ expect(extraH2Sections(existing, incoming)).toBe("## Autorun\n\ngo-live");
1795
+ expect(extraH2Sections("# Law\n\nno headings\n", incoming)).toBe("");
1796
+ expect(extraH2Sections("## \nempty title\n## Autorun\nkeep\n", incoming)).toBe("## Autorun\nkeep");
1797
+ });
1798
+
1799
+ it("mergeReferenceMarkdown appends extras and normalizes a trailing newline", () => {
1800
+ const incoming = "# Law\n\n## Skills naming\n\nstock";
1801
+ expect(mergeReferenceMarkdown("", incoming)).toBe("# Law\n\n## Skills naming\n\nstock\n");
1802
+ expect(mergeReferenceMarkdown("# Law\n\n## Skills naming\n\nstock\n\n## Autorun\n\nX\n", incoming)).toBe(
1803
+ "# Law\n\n## Skills naming\n\nstock\n\n## Autorun\n\nX\n",
1804
+ );
1805
+ });
1806
+
1807
+ it("snapshotReferenceDocs skips generated maps, non-md, and directories", () => {
1808
+ const dest = tmp("ref-snap-");
1809
+ expect(snapshotReferenceDocs(dest)).toEqual({});
1810
+ const dir = path.join(dest, ".rulesync", "reference");
1811
+ fs.mkdirSync(dir, { recursive: true });
1812
+ fs.writeFileSync(path.join(dir, "law.md"), "# Law\n");
1813
+ fs.writeFileSync(path.join(dir, "INDEX.md"), "generated\n");
1814
+ fs.writeFileSync(path.join(dir, "notes.txt"), "nope\n");
1815
+ fs.mkdirSync(path.join(dir, "nested.md"));
1816
+ expect(snapshotReferenceDocs(dest)).toEqual({ "law.md": "# Law\n" });
1817
+ });
1818
+
1819
+ it("remergeReferenceDocs writes extras for packaged refs and leaves consumer-only files", () => {
1820
+ const pkgRoot = stubPkgRoot();
1821
+ const pkgRef = path.join(pkgRoot, ".rulesync", "reference");
1822
+ fs.mkdirSync(pkgRef, { recursive: true });
1823
+ fs.writeFileSync(path.join(pkgRef, "law.md"), "# Law\n\n## Skills naming\n\nstock\n");
1824
+ const dest = tmp("ref-remerge-");
1825
+ const destRef = path.join(dest, ".rulesync", "reference");
1826
+ fs.mkdirSync(destRef, { recursive: true });
1827
+ fs.writeFileSync(
1828
+ path.join(destRef, "law.md"),
1829
+ "# Law\n\n## Skills naming\n\nold\n\n## Autorun\n\nkeep\n",
1830
+ );
1831
+ fs.writeFileSync(path.join(destRef, "stripe.md"), "# Stripe\n");
1832
+ remergeReferenceDocs(
1833
+ dest,
1834
+ {
1835
+ "law.md": fs.readFileSync(path.join(destRef, "law.md"), "utf8"),
1836
+ "stripe.md": "# Stripe\n",
1837
+ },
1838
+ pkgRoot,
1839
+ );
1840
+ expect(fs.readFileSync(path.join(destRef, "law.md"), "utf8")).toBe(
1841
+ "# Law\n\n## Skills naming\n\nstock\n\n## Autorun\n\nkeep\n",
1842
+ );
1843
+ expect(fs.readFileSync(path.join(destRef, "stripe.md"), "utf8")).toBe("# Stripe\n");
1844
+ });
1845
+ });
1846
+
1847
+ describe("pipeline seed", () => {
1848
+ it("writes skip overlay from dest pipeline and does not overwrite an existing file", () => {
1849
+ const dest = tmp("pipe-seed-");
1850
+ const skipped = PIPELINE_COMMANDS.check[1];
1851
+ const src = `export const COMMANDS = {
1852
+ check: [
1853
+ ${PIPELINE_COMMANDS.check.filter((c) => c !== skipped).map((c) => JSON.stringify(c)).join(",\n ")}
1854
+ ],
1855
+ };
1856
+ `;
1857
+ expect(seedPipelineConfig(dest, src)).toBe(true);
1858
+ const cfg = path.join(dest, PIPELINE_CONFIG_REL);
1859
+ expect(JSON.parse(fs.readFileSync(cfg, "utf8"))).toEqual({ check: { skip: [skipped] } });
1860
+ fs.writeFileSync(cfg, '{"check":{"skip":["keep"]}}\n');
1861
+ expect(seedPipelineConfig(dest, src)).toBe(false);
1862
+ expect(JSON.parse(fs.readFileSync(cfg, "utf8"))).toEqual({ check: { skip: ["keep"] } });
1863
+ });
1864
+
1865
+ it("skips seed when dest is not a COMMANDS module or matches stock", () => {
1866
+ const dest = tmp("pipe-seed-skip-");
1867
+ expect(seedPipelineConfig(dest, "")).toBe(false);
1868
+ expect(seedPipelineConfig(dest, "console.log(1)\n")).toBe(false);
1869
+ const stock = `export const COMMANDS = {
1870
+ generate: [${PIPELINE_COMMANDS.generate.map((c) => JSON.stringify(c)).join(", ")}],
1871
+ check: [${PIPELINE_COMMANDS.check.map((c) => JSON.stringify(c)).join(", ")}],
1872
+ watch: [${PIPELINE_COMMANDS.watch.map((c) => JSON.stringify(c)).join(", ")}],
1873
+ };
1874
+ `;
1875
+ expect(seedPipelineConfig(dest, stock)).toBe(false);
1876
+ expect(fs.existsSync(path.join(dest, PIPELINE_CONFIG_REL))).toBe(false);
1877
+ });
1717
1878
  });
1718
1879
 
1719
1880
  describe("init", () => {
@@ -1764,7 +1925,8 @@ describe("init", () => {
1764
1925
  expect(fs.existsSync(path.join(dest, ".mcp.json"))).toBe(false);
1765
1926
 
1766
1927
  for (const name of PRODUCT_FILES) {
1767
- 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);
1768
1930
  }
1769
1931
  expect(fs.statSync(path.join(dest, "scripts", "scrub-text")).isDirectory()).toBe(true);
1770
1932
  expect(fs.readFileSync(path.join(dest, "scripts", "scrub-text", "inside"), "utf8")).toBe(
@@ -1875,6 +2037,7 @@ describe("init", () => {
1875
2037
  "google-workspace",
1876
2038
  "handoff",
1877
2039
  "implement-plan",
2040
+ "jev",
1878
2041
  "listen",
1879
2042
  "pickup",
1880
2043
  "speak",
@@ -1958,9 +2121,13 @@ describe("init", () => {
1958
2121
  expect(claude.split("<!-- grunt:end -->")).toHaveLength(2);
1959
2122
  expect(fs.existsSync(path.join(dest, "scripts", "scrub-text"))).toBe(true);
1960
2123
  expect(fs.existsSync(path.join(dest, "scripts", "grunt-config.mjs"))).toBe(false);
1961
- expect(fs.existsSync(path.join(dest, "scripts", "speak.mjs"))).toBe(true);
1962
- expect(fs.existsSync(path.join(dest, "scripts", "listen.mjs"))).toBe(true);
1963
- 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);
1964
2131
  expect(fs.existsSync(path.join(dest, ".claude", "settings.json"))).toBe(true);
1965
2132
  expect(fs.existsSync(path.join(dest, ".rulesync", "grunt.config.jsonc"))).toBe(false);
1966
2133
  expect(exec.mock.calls.map((c) => c[1])).toEqual([
@@ -1971,6 +2138,84 @@ describe("init", () => {
1971
2138
  ]);
1972
2139
  });
1973
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
+
1974
2219
  it("--skip-globals skips sync:globals:apply", () => {
1975
2220
  const pkgRoot = stubPkgRoot();
1976
2221
  const dest = tmp("grunt-skip-globals-");
@@ -2220,6 +2465,43 @@ describe("init", () => {
2220
2465
  ).toThrow("emit-maps failed");
2221
2466
  });
2222
2467
 
2468
+ it("keeps extra ## sections in dest law.md and INDEX", () => {
2469
+ const dest = tmp("grunt-law-extra-");
2470
+ fs.mkdirSync(path.join(dest, ".rulesync", "reference"), { recursive: true });
2471
+ const pkgLaw = fs.readFileSync(path.join(PKG_ROOT, ".rulesync", "reference", "law.md"), "utf8");
2472
+ fs.writeFileSync(
2473
+ path.join(dest, ".rulesync", "reference", "law.md"),
2474
+ `${pkgLaw.trimEnd()}\n\n## Autorun\n\npush → go-live/\n`,
2475
+ );
2476
+ init(dest, { execFileSync: vi.fn(), skipGlobals: true, packageManager: "npm" });
2477
+ const law = fs.readFileSync(path.join(dest, ".rulesync", "reference", "law.md"), "utf8");
2478
+ expect(law).toMatch(/## Skills naming/);
2479
+ expect(law).toMatch(/## Autorun/);
2480
+ expect(law).toMatch(/push → go-live\//);
2481
+ const index = fs.readFileSync(path.join(dest, ".rulesync", "reference", "INDEX.md"), "utf8");
2482
+ expect(index).toMatch(/## Autorun/);
2483
+ expect(index).toMatch(/push → go-live\//);
2484
+ });
2485
+
2486
+ it("seeds grunt.pipeline.jsonc from dest pipeline omissions during init", () => {
2487
+ const dest = tmp("grunt-pipe-init-");
2488
+ fs.mkdirSync(path.join(dest, "scripts"), { recursive: true });
2489
+ const skipped = PIPELINE_COMMANDS.check.find((c) => c.includes("subagents"));
2490
+ const src = `export const COMMANDS = {
2491
+ check: [
2492
+ ${PIPELINE_COMMANDS.check.filter((c) => c !== skipped).map((c) => JSON.stringify(c)).join(",\n ")}
2493
+ ],
2494
+ };
2495
+ `;
2496
+ fs.writeFileSync(path.join(dest, "scripts", "pipeline.mjs"), src);
2497
+ init(dest, { execFileSync: vi.fn(), skipGlobals: true, packageManager: "npm" });
2498
+ const cfg = JSON.parse(fs.readFileSync(path.join(dest, PIPELINE_CONFIG_REL), "utf8"));
2499
+ expect(cfg.check.skip).toEqual([skipped]);
2500
+ expect(fs.readFileSync(path.join(dest, "scripts", "pipeline.mjs"), "utf8")).toContain(
2501
+ "subagents --check",
2502
+ );
2503
+ });
2504
+
2223
2505
  it("does not stamp package INDEX over dest; re-emits union", () => {
2224
2506
  const dest = tmp("grunt-index-keep-");
2225
2507
  fs.mkdirSync(path.join(dest, ".rulesync", "reference"), { recursive: true });
@@ -2413,16 +2695,31 @@ describe("product scripts are consumer-safe", () => {
2413
2695
  expect(fs.existsSync(path.join(dest, "cli"))).toBe(false);
2414
2696
  expect(fs.existsSync(path.join(dest, "scripts", "guarded-roots.mjs"))).toBe(true);
2415
2697
 
2698
+ const optionalScripts = new Set(["speak.mjs", "listen.mjs", "google-workspace.mjs"]);
2416
2699
  for (const name of PRODUCT_FILES) {
2417
2700
  const file = path.join(dest, "scripts", name);
2701
+ if (optionalScripts.has(name)) {
2702
+ expect(fs.existsSync(file), name).toBe(false);
2703
+ continue;
2704
+ }
2418
2705
  expect(fs.existsSync(file), name).toBe(true);
2419
2706
  const text = fs.readFileSync(file, "utf8");
2420
2707
  for (const spec of relativeSpecs(text, RELATIVE_IMPORT_RE)) {
2421
2708
  const resolved = path.resolve(path.dirname(file), spec);
2422
- 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);
2423
2711
  }
2424
2712
  }
2425
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
+
2426
2723
  const loaded = spawnDest(dest, ["./scripts/guarded-roots.mjs"]);
2427
2724
  expect(loaded.status).toBe(1);
2428
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.4",
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",