@lovrozagar/grunt 0.3.8 → 0.3.9

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.
Files changed (47) hide show
  1. package/.agents/agents/orchestrator.md +9 -9
  2. package/.agents/hooks.json +1 -1
  3. package/.agents/skills/cascade/SKILL.md +4 -2
  4. package/.agents/skills/explain/SKILL.md +5 -3
  5. package/.agents/skills/handoff/SKILL.md +3 -2
  6. package/.agents/skills/parent/SKILL.md +3 -8
  7. package/.agents/skills/solo/SKILL.md +2 -0
  8. package/.claude/agents/orchestrator.md +10 -10
  9. package/.claude/settings.json +1 -1
  10. package/.claude/skills/cascade/SKILL.md +4 -2
  11. package/.claude/skills/explain/SKILL.md +5 -3
  12. package/.claude/skills/handoff/SKILL.md +3 -2
  13. package/.claude/skills/parent/SKILL.md +3 -8
  14. package/.claude/skills/solo/SKILL.md +2 -0
  15. package/.codex/agents/orchestrator.toml +8 -5
  16. package/.codex/hooks.json +1 -1
  17. package/.grok/agents/orchestrator.md +9 -6
  18. package/.grok/hooks/orchestrate-parent.js +2 -1
  19. package/.grok/skills/cascade/SKILL.md +4 -2
  20. package/.grok/skills/explain/SKILL.md +5 -4
  21. package/.grok/skills/handoff/SKILL.md +3 -2
  22. package/.grok/skills/implement-plan/SKILL.md +8 -12
  23. package/.grok/skills/parent/SKILL.md +3 -9
  24. package/.grok/skills/solo/SKILL.md +2 -0
  25. package/.grok/skills/write-plan/SKILL.md +4 -8
  26. package/.rulesync/hooks.jsonc +2 -1
  27. package/.rulesync/reference/cascade.md +2 -2
  28. package/.rulesync/reference/hooks.md +2 -2
  29. package/.rulesync/rules/CLAUDE.md +7 -3
  30. package/.rulesync/rules/overview.md +7 -3
  31. package/.rulesync/skills/cascade/SKILL.md +4 -2
  32. package/.rulesync/skills/explain/SKILL.md +5 -3
  33. package/.rulesync/skills/handoff/SKILL.md +3 -2
  34. package/.rulesync/skills/parent/SKILL.md +3 -9
  35. package/.rulesync/skills/solo/SKILL.md +2 -0
  36. package/.rulesync/subagents/orchestrator.md +11 -8
  37. package/AGENTS.md +7 -3
  38. package/CLAUDE.md +7 -3
  39. package/README.md +4 -2
  40. package/bin/grunt.js +3 -1
  41. package/cli/grunt.mjs +148 -15
  42. package/cli/grunt.test.ts +304 -25
  43. package/cli/init.mjs +65 -43
  44. package/cli/init.test.ts +78 -0
  45. package/cli/prompt.mjs +36 -0
  46. package/cli/prompt.test.ts +199 -0
  47. package/package.json +4 -1
package/cli/grunt.test.ts CHANGED
@@ -4,26 +4,49 @@ import { fileURLToPath } from "node:url";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
 
6
6
  const init = vi.hoisted(() => vi.fn());
7
+ const destAlreadyInited = vi.hoisted(() => vi.fn(() => false));
8
+ const shouldAutoSkipGlobals = vi.hoisted(() => vi.fn(() => false));
7
9
  const execFileSync = vi.hoisted(() => vi.fn());
10
+ const isInteractive = vi.hoisted(() => vi.fn(() => false));
11
+ const select = vi.hoisted(() => vi.fn());
12
+ const confirm = vi.hoisted(() => vi.fn());
13
+ const spinner = vi.hoisted(() =>
14
+ vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
15
+ );
16
+ const bailIfCancel = vi.hoisted(() => vi.fn((v) => v));
8
17
 
9
- vi.mock("./init.mjs", () => ({ init }));
18
+ vi.mock("./init.mjs", () => ({ init, destAlreadyInited, shouldAutoSkipGlobals }));
10
19
  vi.mock("node:child_process", () => ({ execFileSync }));
20
+ vi.mock("./prompt.mjs", () => ({
21
+ isInteractive,
22
+ select,
23
+ confirm,
24
+ spinner,
25
+ bailIfCancel,
26
+ }));
11
27
 
12
- import { start } from "./grunt.mjs";
28
+ import { parseArgv, start } from "./grunt.mjs";
13
29
 
14
30
  const USAGE = `Usage: grunt [command]
15
31
 
16
- Default (no command): init — full setup
32
+ Default (no command): TTY menu; else init — full setup
17
33
 
18
34
  Commands:
19
- init Full setup: merge SoT, npm install, rulesync:generate, sync:globals:apply, rulesync:check
20
- generate npm run rulesync:generate
21
- check npm run rulesync:check
22
- help Show this help
23
- version Print package version
35
+ init Full setup: merge SoT, npm install, rulesync:generate, sync:globals:apply, rulesync:check
36
+ generate npm run rulesync:generate
37
+ check npm run rulesync:check
38
+ sync-globals npm run sync:globals (dry-run; --apply to write)
39
+ purge-mcps npm run purge:global-mcps (dry-run; --apply to write)
40
+ doctor npm run rulesync:doctor
41
+ help Show this help
42
+ version Print package version
24
43
 
25
44
  Flags:
26
- --skip-globals Skip sync:globals:apply (auto-skipped when already initialized)
45
+ --skip-globals Skip sync:globals:apply (auto-skipped when already initialized)
46
+ --yes, -y Non-interactive (not --apply)
47
+ --non-interactive Same as --yes
48
+ --apply Write for sync-globals / purge-mcps
49
+ --host <id> sync-globals host
27
50
  `;
28
51
 
29
52
  const pkgRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -48,7 +71,19 @@ describe("start", () => {
48
71
  return true;
49
72
  }) as typeof process.stdout.write;
50
73
  init.mockReset();
74
+ destAlreadyInited.mockReset();
75
+ destAlreadyInited.mockReturnValue(false);
76
+ shouldAutoSkipGlobals.mockReset();
77
+ shouldAutoSkipGlobals.mockReturnValue(false);
51
78
  execFileSync.mockReset();
79
+ isInteractive.mockReset();
80
+ isInteractive.mockReturnValue(false);
81
+ select.mockReset();
82
+ confirm.mockReset();
83
+ spinner.mockReset();
84
+ spinner.mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() }));
85
+ bailIfCancel.mockReset();
86
+ bailIfCancel.mockImplementation((v) => v);
52
87
  });
53
88
 
54
89
  afterEach(() => {
@@ -57,33 +92,35 @@ describe("start", () => {
57
92
  process.stdout.write = stdoutWrite;
58
93
  });
59
94
 
60
- it.each(["help", "--help", "-h"])("%s writes usage", (cmd) => {
95
+ it.each(["help", "--help", "-h"])("%s writes usage", async (cmd) => {
61
96
  process.argv = ["node", "grunt", cmd];
62
- start();
97
+ await start();
63
98
  expect(chunks.join("")).toBe(USAGE);
64
99
  expect(process.exitCode).toBe(0);
65
100
  expect(init).not.toHaveBeenCalled();
66
101
  expect(execFileSync).not.toHaveBeenCalled();
102
+ expect(select).not.toHaveBeenCalled();
67
103
  });
68
104
 
69
- it.each(["version", "--version", "-v"])("%s writes package version", (cmd) => {
105
+ it.each(["version", "--version", "-v"])("%s writes package version", async (cmd) => {
70
106
  process.argv = ["node", "grunt", cmd];
71
- start();
107
+ await start();
72
108
  expect(chunks.join("")).toBe(`${version}\n`);
73
109
  expect(process.exitCode).toBe(0);
74
110
  });
75
111
 
76
- it("no-arg runs init on cwd", () => {
112
+ it("no-arg runs init on cwd", async () => {
77
113
  process.argv = ["node", "grunt"];
78
- start();
114
+ await start();
79
115
  expect(init).toHaveBeenCalledOnce();
80
116
  expect(init).toHaveBeenCalledWith(process.cwd(), { skipGlobals: false });
81
117
  expect(execFileSync).not.toHaveBeenCalled();
118
+ expect(select).not.toHaveBeenCalled();
82
119
  });
83
120
 
84
- it("init runs init on cwd", () => {
121
+ it("init runs init on cwd", async () => {
85
122
  process.argv = ["node", "grunt", "init"];
86
- start();
123
+ await start();
87
124
  expect(init).toHaveBeenCalledOnce();
88
125
  expect(init).toHaveBeenCalledWith(process.cwd(), { skipGlobals: false });
89
126
  });
@@ -92,16 +129,28 @@ describe("start", () => {
92
129
  ["node", "grunt", "--skip-globals"],
93
130
  ["node", "grunt", "init", "--skip-globals"],
94
131
  ["node", "grunt", "--skip-globals", "init"],
95
- ])("passes skipGlobals when argv is %s", (...argv) => {
132
+ ])("passes skipGlobals when argv is %s", async (...argv) => {
96
133
  process.argv = argv;
97
- start();
134
+ await start();
98
135
  expect(init).toHaveBeenCalledOnce();
99
136
  expect(init).toHaveBeenCalledWith(process.cwd(), { skipGlobals: true });
100
137
  });
101
138
 
102
- it("generate npm-runs rulesync:generate", () => {
139
+ it.each(["--yes", "-y", "--non-interactive"])(
140
+ "%s no-arg still init, no menu",
141
+ async (flag) => {
142
+ process.argv = ["node", "grunt", flag];
143
+ await start();
144
+ expect(isInteractive).toHaveBeenCalledOnce();
145
+ expect(select).not.toHaveBeenCalled();
146
+ expect(init).toHaveBeenCalledOnce();
147
+ expect(init).toHaveBeenCalledWith(process.cwd(), { skipGlobals: false });
148
+ },
149
+ );
150
+
151
+ it("generate npm-runs rulesync:generate", async () => {
103
152
  process.argv = ["node", "grunt", "generate"];
104
- start();
153
+ await start();
105
154
  expect(execFileSync).toHaveBeenCalledOnce();
106
155
  expect(execFileSync).toHaveBeenCalledWith("npm", ["run", "rulesync:generate"], {
107
156
  cwd: process.cwd(),
@@ -110,9 +159,9 @@ describe("start", () => {
110
159
  expect(init).not.toHaveBeenCalled();
111
160
  });
112
161
 
113
- it("check npm-runs rulesync:check", () => {
162
+ it("check npm-runs rulesync:check", async () => {
114
163
  process.argv = ["node", "grunt", "check"];
115
- start();
164
+ await start();
116
165
  expect(execFileSync).toHaveBeenCalledOnce();
117
166
  expect(execFileSync).toHaveBeenCalledWith("npm", ["run", "rulesync:check"], {
118
167
  cwd: process.cwd(),
@@ -120,12 +169,242 @@ describe("start", () => {
120
169
  });
121
170
  });
122
171
 
123
- it("unknown writes usage and exitCode 1", () => {
172
+ it("sync-globals dry-run default; --yes is not apply", async () => {
173
+ process.argv = ["node", "grunt", "sync-globals", "--yes"];
174
+ await start();
175
+ expect(execFileSync).toHaveBeenCalledOnce();
176
+ expect(execFileSync).toHaveBeenCalledWith("npm", ["run", "sync:globals"], {
177
+ cwd: process.cwd(),
178
+ stdio: "inherit",
179
+ });
180
+ });
181
+
182
+ it("sync-globals --host grok last pair passes host", async () => {
183
+ process.argv = ["node", "grunt", "sync-globals", "--host", "grok"];
184
+ await start();
185
+ expect(execFileSync).toHaveBeenCalledWith(
186
+ "npm",
187
+ ["run", "sync:globals", "--", "--host", "grok"],
188
+ { cwd: process.cwd(), stdio: "inherit" },
189
+ );
190
+ });
191
+
192
+ it("sync-globals --apply --host passes extra args", async () => {
193
+ process.argv = ["node", "grunt", "sync-globals", "--apply", "--host", "grok"];
194
+ await start();
195
+ expect(execFileSync).toHaveBeenCalledWith(
196
+ "npm",
197
+ ["run", "sync:globals:apply", "--", "--host", "grok"],
198
+ { cwd: process.cwd(), stdio: "inherit" },
199
+ );
200
+ });
201
+
202
+ it("sync-globals --host last without id: usage exit 1", async () => {
203
+ process.argv = ["node", "grunt", "sync-globals", "--host"];
204
+ await start();
205
+ expect(chunks.join("")).toBe(USAGE);
206
+ expect(process.exitCode).toBe(1);
207
+ expect(execFileSync).not.toHaveBeenCalled();
208
+ });
209
+
210
+ it("sync-globals --host=claude dry-run", async () => {
211
+ process.argv = ["node", "grunt", "sync-globals", "--host=claude"];
212
+ await start();
213
+ expect(execFileSync).toHaveBeenCalledWith(
214
+ "npm",
215
+ ["run", "sync:globals", "--", "--host", "claude"],
216
+ { cwd: process.cwd(), stdio: "inherit" },
217
+ );
218
+ });
219
+
220
+ it("purge-mcps dry-run default", async () => {
221
+ process.argv = ["node", "grunt", "purge-mcps"];
222
+ await start();
223
+ expect(execFileSync).toHaveBeenCalledWith("npm", ["run", "purge:global-mcps"], {
224
+ cwd: process.cwd(),
225
+ stdio: "inherit",
226
+ });
227
+ });
228
+
229
+ it("purge-mcps --apply", async () => {
230
+ process.argv = ["node", "grunt", "--apply", "purge-mcps"];
231
+ await start();
232
+ expect(execFileSync).toHaveBeenCalledWith(
233
+ "npm",
234
+ ["run", "purge:global-mcps:apply"],
235
+ { cwd: process.cwd(), stdio: "inherit" },
236
+ );
237
+ });
238
+
239
+ it("doctor npm-runs rulesync:doctor", async () => {
240
+ process.argv = ["node", "grunt", "doctor"];
241
+ await start();
242
+ expect(execFileSync).toHaveBeenCalledWith("npm", ["run", "rulesync:doctor"], {
243
+ cwd: process.cwd(),
244
+ stdio: "inherit",
245
+ });
246
+ });
247
+
248
+ it("unknown writes usage and exitCode 1", async () => {
124
249
  process.argv = ["node", "grunt", "nope"];
125
- start();
250
+ await start();
126
251
  expect(chunks.join("")).toBe(USAGE);
127
252
  expect(process.exitCode).toBe(1);
128
253
  expect(init).not.toHaveBeenCalled();
129
254
  expect(execFileSync).not.toHaveBeenCalled();
130
255
  });
256
+
257
+ it("TTY no cmd shows menu default init", async () => {
258
+ isInteractive.mockReturnValue(true);
259
+ select.mockResolvedValue("init");
260
+ confirm.mockResolvedValue(true);
261
+ process.argv = ["node", "grunt"];
262
+ await start();
263
+ expect(select).toHaveBeenCalledOnce();
264
+ const opts = select.mock.calls[0][0] as {
265
+ initialValue: string;
266
+ options: { value: string }[];
267
+ };
268
+ expect(opts.initialValue).toBe("init");
269
+ expect(opts.options.map((o) => o.value)).toEqual([
270
+ "init",
271
+ "generate",
272
+ "check",
273
+ "sync-globals",
274
+ "purge-mcps",
275
+ "doctor",
276
+ "help",
277
+ "quit",
278
+ ]);
279
+ expect(init).toHaveBeenCalled();
280
+ expect(isInteractive).toHaveBeenCalledOnce();
281
+ });
282
+
283
+ it("TTY menu generate", async () => {
284
+ isInteractive.mockReturnValue(true);
285
+ select.mockResolvedValue("generate");
286
+ process.argv = ["node", "grunt"];
287
+ await start();
288
+ expect(execFileSync).toHaveBeenCalledWith("npm", ["run", "rulesync:generate"], {
289
+ cwd: process.cwd(),
290
+ stdio: "inherit",
291
+ });
292
+ });
293
+
294
+ it("TTY menu help writes usage", async () => {
295
+ isInteractive.mockReturnValue(true);
296
+ select.mockResolvedValue("help");
297
+ process.argv = ["node", "grunt"];
298
+ await start();
299
+ expect(chunks.join("")).toBe(USAGE);
300
+ expect(process.exitCode).toBe(0);
301
+ });
302
+
303
+ it("TTY menu quit does nothing", async () => {
304
+ isInteractive.mockReturnValue(true);
305
+ select.mockResolvedValue("quit");
306
+ process.argv = ["node", "grunt"];
307
+ await start();
308
+ expect(init).not.toHaveBeenCalled();
309
+ expect(execFileSync).not.toHaveBeenCalled();
310
+ expect(process.exitCode).toBe(0);
311
+ });
312
+
313
+ it("TTY cancel exits 0", async () => {
314
+ isInteractive.mockReturnValue(true);
315
+ const exit = vi.spyOn(process, "exit").mockImplementation((() => {
316
+ throw new Error("EXIT");
317
+ }) as typeof process.exit);
318
+ select.mockImplementation(async () => {
319
+ process.exit(0);
320
+ });
321
+ process.argv = ["node", "grunt"];
322
+ await expect(start()).rejects.toThrow("EXIT");
323
+ expect(exit).toHaveBeenCalledWith(0);
324
+ expect(init).not.toHaveBeenCalled();
325
+ exit.mockRestore();
326
+ });
327
+
328
+ it("TTY init already-inited decline skips init", async () => {
329
+ isInteractive.mockReturnValue(true);
330
+ destAlreadyInited.mockReturnValue(true);
331
+ confirm.mockResolvedValue(false);
332
+ process.argv = ["node", "grunt", "init"];
333
+ await start();
334
+ expect(confirm).toHaveBeenCalled();
335
+ expect(init).not.toHaveBeenCalled();
336
+ });
337
+
338
+ it("TTY init confirms globals; spinner onPhase stop before inherit", async () => {
339
+ isInteractive.mockReturnValue(true);
340
+ shouldAutoSkipGlobals.mockReturnValue(true);
341
+ const spin = { start: vi.fn(), stop: vi.fn() };
342
+ spinner.mockReturnValue(spin);
343
+ confirm.mockResolvedValue(true);
344
+ init.mockImplementation((_dest: string, opts: { onPhase?: (n: string, a: string) => void }) => {
345
+ opts.onPhase?.("merge", "start");
346
+ opts.onPhase?.("merge", "stop");
347
+ opts.onPhase?.("install", "start");
348
+ opts.onPhase?.("install", "stop");
349
+ });
350
+ process.argv = ["node", "grunt", "init"];
351
+ await start();
352
+ expect(confirm).toHaveBeenCalled();
353
+ const globalsConfirm = confirm.mock.calls.find(
354
+ (c) => String((c[0] as { message?: string }).message).includes("globals"),
355
+ );
356
+ expect(globalsConfirm?.[0]).toMatchObject({ initialValue: false });
357
+ expect(init).toHaveBeenCalledWith(
358
+ process.cwd(),
359
+ expect.objectContaining({
360
+ skipGlobals: false,
361
+ applyGlobals: true,
362
+ onPhase: expect.any(Function),
363
+ }),
364
+ );
365
+ expect(isInteractive).toHaveBeenCalledOnce();
366
+ expect(spin.start).toHaveBeenCalledWith("merge");
367
+ expect(spin.stop).toHaveBeenCalled();
368
+ expect(spin.start).toHaveBeenCalledWith("install");
369
+ });
370
+
371
+ it("TTY init --skip-globals default confirm false maps to skip", async () => {
372
+ isInteractive.mockReturnValue(true);
373
+ confirm.mockResolvedValue(false);
374
+ process.argv = ["node", "grunt", "init", "--skip-globals"];
375
+ await start();
376
+ expect(init).toHaveBeenCalledWith(
377
+ process.cwd(),
378
+ expect.objectContaining({
379
+ skipGlobals: true,
380
+ applyGlobals: false,
381
+ onPhase: expect.any(Function),
382
+ }),
383
+ );
384
+ expect(isInteractive).toHaveBeenCalledOnce();
385
+ });
386
+ });
387
+
388
+ describe("parseArgv --host", () => {
389
+ it("last flag pair --host <id>", () => {
390
+ expect(parseArgv(["sync-globals", "--host", "grok"])).toMatchObject({
391
+ cmd: "sync-globals",
392
+ host: "grok",
393
+ hostError: false,
394
+ });
395
+ });
396
+
397
+ it("--host last without id is hostError", () => {
398
+ expect(parseArgv(["sync-globals", "--host"])).toMatchObject({ hostError: true });
399
+ });
400
+
401
+ it("--host= empty is hostError", () => {
402
+ expect(parseArgv(["sync-globals", "--host="])).toMatchObject({ hostError: true });
403
+ });
404
+
405
+ it("--host followed by flag is hostError", () => {
406
+ expect(parseArgv(["sync-globals", "--host", "--apply"])).toMatchObject({
407
+ hostError: true,
408
+ });
409
+ });
131
410
  });
package/cli/init.mjs CHANGED
@@ -65,7 +65,7 @@ function destHasGruntSentinel(dest) {
65
65
  })
66
66
  }
67
67
 
68
- function destAlreadyInited(dest) {
68
+ export function destAlreadyInited(dest) {
69
69
  return (
70
70
  fs.existsSync(path.join(dest, "scripts", "telemetry.mjs")) ||
71
71
  fs.existsSync(path.join(dest, ".grok", "hooks", "orchestrate-parent.js")) ||
@@ -73,6 +73,10 @@ function destAlreadyInited(dest) {
73
73
  )
74
74
  }
75
75
 
76
+ export function shouldAutoSkipGlobals(dest) {
77
+ return destHasGruntSentinel(dest) || fs.existsSync(path.join(dest, "scripts", "telemetry.mjs"))
78
+ }
79
+
76
80
  export function mergeGuardedMarkdown(dest, pkgRoot, file, { alreadyInited = false } = {}) {
77
81
  const src = path.join(pkgRoot, file)
78
82
  const d = path.join(dest, file)
@@ -213,63 +217,81 @@ export function mergePackageJson(dest, pkgRoot) {
213
217
  fs.writeFileSync(destPath, `${JSON.stringify(destPkg, null, 2)}\n`)
214
218
  }
215
219
 
216
- export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileSync, skipGlobals = false } = {}) {
220
+ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileSync, skipGlobals = false, applyGlobals, onPhase } = {}) {
217
221
  dest = path.resolve(dest)
218
222
  const pkgRoot = path.resolve(pkgRootOpt ?? PKG_ROOT)
219
223
  const alreadyInited = destAlreadyInited(dest)
220
224
  const skipGlobalsApply =
221
- skipGlobals || destHasGruntSentinel(dest) || fs.existsSync(path.join(dest, "scripts", "telemetry.mjs"))
222
-
223
- for (const dir of COPY_DIRS) {
224
- const src = path.join(pkgRoot, dir)
225
- const d = path.join(dest, dir)
226
- fs.mkdirSync(d, { recursive: true })
227
- if (samePath(src, d)) continue
228
- if (dir === ".claude") {
229
- fs.cpSync(src, d, {
230
- recursive: true,
231
- force: true,
232
- filter: (s) => path.basename(s) !== "settings.json",
233
- })
234
- mergeClaudeSettings(dest, pkgRoot)
235
- } else {
236
- fs.cpSync(src, d, { recursive: true, force: true })
225
+ applyGlobals === true ? false : skipGlobals || shouldAutoSkipGlobals(dest)
226
+
227
+ const phase = (name, fn) => {
228
+ onPhase?.(name, "start")
229
+ try {
230
+ fn()
231
+ } finally {
232
+ onPhase?.(name, "stop")
237
233
  }
238
234
  }
239
235
 
240
- let skippedSideFile = false
241
- for (const file of GUARDED_MD_FILES) {
242
- if (mergeGuardedMarkdown(dest, pkgRoot, file, { alreadyInited }) === "skipped-side-file") {
243
- skippedSideFile = true
236
+ const self = samePath(dest, pkgRoot)
237
+
238
+ phase("merge", () => {
239
+ for (const dir of COPY_DIRS) {
240
+ const src = path.join(pkgRoot, dir)
241
+ const d = path.join(dest, dir)
242
+ fs.mkdirSync(d, { recursive: true })
243
+ if (samePath(src, d)) continue
244
+ if (dir === ".claude") {
245
+ fs.cpSync(src, d, {
246
+ recursive: true,
247
+ force: true,
248
+ filter: (s) => path.basename(s) !== "settings.json",
249
+ })
250
+ mergeClaudeSettings(dest, pkgRoot)
251
+ } else {
252
+ fs.cpSync(src, d, { recursive: true, force: true })
253
+ }
244
254
  }
245
- }
246
- if (skippedSideFile) {
247
- console.log("AGENTS.md/CLAUDE.md lack markers and were left alone")
248
- }
249
255
 
250
- fs.mkdirSync(path.join(dest, "scripts"), { recursive: true })
251
- for (const name of PRODUCT_SCRIPTS) {
252
- const src = path.join(pkgRoot, "scripts", name)
253
- const d = path.join(dest, "scripts", name)
254
- if (samePath(src, d)) continue
255
- const st = fs.statSync(src)
256
- if (st.isDirectory()) fs.cpSync(src, d, { recursive: true, force: true })
257
- else fs.copyFileSync(src, d)
258
- }
256
+ let skippedSideFile = false
257
+ for (const file of GUARDED_MD_FILES) {
258
+ if (mergeGuardedMarkdown(dest, pkgRoot, file, { alreadyInited }) === "skipped-side-file") {
259
+ skippedSideFile = true
260
+ }
261
+ }
262
+ if (skippedSideFile) {
263
+ console.log("AGENTS.md/CLAUDE.md lack markers and were left alone")
264
+ }
259
265
 
260
- fs.mkdirSync(path.join(dest, ".tmp"), { recursive: true })
261
- mergeGitignore(dest)
266
+ fs.mkdirSync(path.join(dest, "scripts"), { recursive: true })
267
+ for (const name of PRODUCT_SCRIPTS) {
268
+ const src = path.join(pkgRoot, "scripts", name)
269
+ const d = path.join(dest, "scripts", name)
270
+ if (samePath(src, d)) continue
271
+ const st = fs.statSync(src)
272
+ if (st.isDirectory()) fs.cpSync(src, d, { recursive: true, force: true })
273
+ else fs.copyFileSync(src, d)
274
+ }
275
+
276
+ fs.mkdirSync(path.join(dest, ".tmp"), { recursive: true })
277
+ mergeGitignore(dest)
278
+
279
+ if (!self) mergePackageJson(dest, pkgRoot)
280
+ })
262
281
 
263
- const self = samePath(dest, pkgRoot)
264
282
  // Self-skip: file/dir merge + .tmp + gitignore only. No package.json merge,
265
283
  // npm install, or generate/sync/check (would mutate this package in-place).
266
284
  if (self) return
267
285
 
268
- mergePackageJson(dest, pkgRoot)
269
- exec("npm", ["install"], { cwd: dest, stdio: "inherit" })
270
- exec("npm", ["run", "rulesync:generate"], { cwd: dest, stdio: "inherit" })
286
+ const runNpm = (name, args) => {
287
+ onPhase?.(name, "start")
288
+ onPhase?.(name, "stop")
289
+ exec("npm", args, { cwd: dest, stdio: "inherit" })
290
+ }
291
+ runNpm("install", ["install"])
292
+ runNpm("generate", ["run", "rulesync:generate"])
271
293
  if (!skipGlobalsApply) {
272
- exec("npm", ["run", "sync:globals:apply"], { cwd: dest, stdio: "inherit" })
294
+ runNpm("sync-globals", ["run", "sync:globals:apply"])
273
295
  }
274
- exec("npm", ["run", "rulesync:check"], { cwd: dest, stdio: "inherit" })
296
+ runNpm("check", ["run", "rulesync:check"])
275
297
  }
package/cli/init.test.ts CHANGED
@@ -3,12 +3,14 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import {
6
+ destAlreadyInited,
6
7
  init,
7
8
  mergeClaudeSettings,
8
9
  mergeGitignore,
9
10
  mergeGuardedMarkdown,
10
11
  mergePackageJson,
11
12
  samePath,
13
+ shouldAutoSkipGlobals,
12
14
  } from "./init.mjs";
13
15
 
14
16
  const tmpDirs: string[] = [];
@@ -732,6 +734,21 @@ describe("init", () => {
732
734
  ]);
733
735
  });
734
736
 
737
+ it("applyGlobals true still applies when telemetry auto-skip would fire", () => {
738
+ const pkgRoot = stubPkgRoot();
739
+ const dest = tmp("grunt-force-globals-");
740
+ fs.mkdirSync(path.join(dest, "scripts"), { recursive: true });
741
+ fs.writeFileSync(path.join(dest, "scripts", "telemetry.mjs"), "existing");
742
+ const exec = vi.fn();
743
+ init(dest, { pkgRoot, execFileSync: exec, applyGlobals: true });
744
+ expect(exec.mock.calls.map((c) => c[1])).toEqual([
745
+ ["install"],
746
+ ["run", "rulesync:generate"],
747
+ ["run", "sync:globals:apply"],
748
+ ["run", "rulesync:check"],
749
+ ]);
750
+ });
751
+
735
752
  it("auto-skip globals when telemetry.mjs exists", () => {
736
753
  const pkgRoot = stubPkgRoot();
737
754
  const dest = tmp("grunt-auto-tel-");
@@ -800,4 +817,65 @@ describe("init", () => {
800
817
  ]);
801
818
  logSpy.mockRestore();
802
819
  });
820
+
821
+ it("onPhase merge then npm phases stop before exec", () => {
822
+ const pkgRoot = stubPkgRoot();
823
+ const dest = tmp("grunt-onphase-");
824
+ const exec = vi.fn();
825
+ const onPhase = vi.fn();
826
+ init(dest, { pkgRoot, execFileSync: exec, onPhase });
827
+ expect(onPhase.mock.calls).toEqual([
828
+ ["merge", "start"],
829
+ ["merge", "stop"],
830
+ ["install", "start"],
831
+ ["install", "stop"],
832
+ ["generate", "start"],
833
+ ["generate", "stop"],
834
+ ["sync-globals", "start"],
835
+ ["sync-globals", "stop"],
836
+ ["check", "start"],
837
+ ["check", "stop"],
838
+ ]);
839
+ expect(exec.mock.calls.map((c) => c[1])).toEqual([
840
+ ["install"],
841
+ ["run", "rulesync:generate"],
842
+ ["run", "sync:globals:apply"],
843
+ ["run", "rulesync:check"],
844
+ ]);
845
+ });
846
+
847
+ it("self-skip only merge onPhase", () => {
848
+ const pkgRoot = stubPkgRoot();
849
+ const onPhase = vi.fn();
850
+ init(pkgRoot, { pkgRoot, onPhase });
851
+ expect(onPhase.mock.calls).toEqual([
852
+ ["merge", "start"],
853
+ ["merge", "stop"],
854
+ ]);
855
+ });
803
856
  });
857
+
858
+ describe("destAlreadyInited / shouldAutoSkipGlobals", () => {
859
+ it("empty dest is neither", () => {
860
+ const dest = tmp("inspect-empty-");
861
+ expect(destAlreadyInited(dest)).toBe(false);
862
+ expect(shouldAutoSkipGlobals(dest)).toBe(false);
863
+ });
864
+
865
+ it(".rulesync means inited, not auto-skip", () => {
866
+ const dest = tmp("inspect-rulesync-");
867
+ fs.mkdirSync(path.join(dest, ".rulesync"));
868
+ expect(destAlreadyInited(dest)).toBe(true);
869
+ expect(shouldAutoSkipGlobals(dest)).toBe(false);
870
+ });
871
+
872
+ it("sentinel auto-skips globals", () => {
873
+ const dest = tmp("inspect-sent-");
874
+ fs.writeFileSync(
875
+ path.join(dest, "AGENTS.md"),
876
+ "<!-- grunt:begin -->\nx\n<!-- grunt:end -->\n",
877
+ );
878
+ expect(shouldAutoSkipGlobals(dest)).toBe(true);
879
+ });
880
+ });
881
+