@lovrozagar/grunt 0.6.0 → 0.6.2

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.
@@ -36,6 +36,7 @@ Cheap outline. Not a file dump.
36
36
 
37
37
  ## Scripts
38
38
  - `scripts/pipeline.mjs` — inner generate/check/watch chain (rulesync + emit-* + hooks-union / check-globals). Called by `guarded-roots`; not a public npm script (`rulesync:generate` `rulesync:check` `rulesync:watch` only)
39
+ - `scripts/guarded-md.mjs` — sentinel merge for `AGENTS.md` `CLAUDE.md` `GEMINI.md`; copied into consumers
39
40
  - `scripts/guarded-roots.mjs` — snapshot/remerge `AGENTS.md` `CLAUDE.md` `GEMINI.md` around pipeline; check interiors wrapper
40
41
  - `scripts/check-globals.mjs` — `$HOME/.grok/config.toml` `[agent].name==orchestrator` + `[features].two_pass_compaction==true`
41
42
  - `scripts/gate-fat-tools.mjs`
package/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.2] - 2026-09-17
11
+
12
+ ### Changed
13
+
14
+ - TTY init defaults `Apply global prompt optimizations? (recommended)` to Yes (`--skip-globals` still defaults No)
15
+
16
+ ### Fixed
17
+
18
+ - Init in `$HOME` no longer fails `emit-mcp-policy --check` after globals write `[features]` into the same `~/.grok/config.toml`
19
+ - TTY init spinner `stop` keeps the phase name (no empty `◇`)
20
+
21
+ ## [0.6.1] - 2026-09-17
22
+
23
+ ### Changed
24
+
25
+ - TTY init asks `Apply global prompt optimizations? (recommended)` instead of `Apply globals?`
26
+
27
+ ### Fixed
28
+
29
+ - Windows tests: host `whichBin` PATHEXT/`F_OK`, cmd shims, and `path.resolve` scratch paths
30
+ - Consumer `init` generate: product scripts imported `../cli/` which is not copied. Sentinel helpers live in `scripts/guarded-md.mjs`; setup prompts live in `scripts/prompt.mjs` / `scripts/interactive.mjs`. After init, tests spawn `node ./scripts/guarded-roots.mjs generate` and require every copied script's relative imports to exist on dest
31
+
10
32
  ## [0.6.0] - 2026-09-17
11
33
 
12
34
  ### Added
package/README.md CHANGED
@@ -95,7 +95,8 @@ Rulesync schema doctor is separate: `npm run grunt:rulesync:doctor`.
95
95
 
96
96
  - Merge SoT `npm install` `grunt:rulesync:generate` `grunt:sync:globals:apply` `grunt:rulesync:check`
97
97
  - `--skip-globals` skips apply
98
- - Re-init auto-skips globals when `<!-- grunt:begin -->` in `AGENTS.md`/`CLAUDE.md`
98
+ - TTY init asks `Apply global prompt optimizations? (recommended)` (default Yes; `--skip-globals` defaults No)
99
+ - Non-interactive re-init auto-skips globals when `<!-- grunt:begin -->` in `AGENTS.md`/`CLAUDE.md`
99
100
  - First init (no sentinel) applies globals unless flagged
100
101
  - Owned trees/scripts refresh; extra `.rulesync` files kept; patches to grunt-owned files lost
101
102
  - `cp` cannot delete dest extras. Init/upgrade then prune: retired skills `parent` `solo` `cascade`, agents `implementer` `thinker` (plus `.grok/roles/{implementer,thinker}.toml` and `.gemini/agents/{name}/`), scripts `telemetry.mjs` `grunt-config.mjs`, paths `.grok/parent.md` `.grok/skills/shared` `.rulesync/grunt.config.jsonc` plus local overlay and example, and reserved skill dirs this package no longer ships. Consumer extras stay.
@@ -346,7 +347,7 @@ Repo-relative (repository root):
346
347
 
347
348
  ## Layout
348
349
 
349
- Published (`package.json` `files`): `bin/grunt.js` `cli` `scripts/check-globals.mjs` `scripts/emit-agent-shell-tools.mjs` `scripts/emit-gemini.mjs` `scripts/guarded-roots.mjs` `scripts/emit-mcp-policy.mjs` `scripts/gate-fat-tools.mjs` `scripts/hooks-union.mjs` `scripts/pipeline.mjs` `scripts/grunt-job.mjs` `scripts/parse-need.mjs` `scripts/persist-handoff.mjs` `scripts/persist-implementation.mjs` `scripts/persist-tmp.mjs` `scripts/persist-plan.mjs` `scripts/purge-global-mcps.mjs` `scripts/scrub-spawn-prompt.mjs` `scripts/scrub-text-lib.mjs` `scripts/sync-global-settings.mjs` `scripts/browser.mjs` `scripts/speak.mjs` `scripts/listen.mjs` `scripts/google-workspace.mjs` `scripts/setup.mjs` `scripts/doctor.mjs` `scripts/scrub-text` `.rulesync` `.grok` `.codex` `.claude` `.agents` `AGENTS.md` `CLAUDE.md` `.mcp.json` `README.md` `LICENSE` `CHANGELOG.md`
350
+ Published (`package.json` `files`): `bin/grunt.js` `cli` `scripts/check-globals.mjs` `scripts/emit-agent-shell-tools.mjs` `scripts/emit-gemini.mjs` `scripts/emit-maps.mjs` `scripts/guarded-md.mjs` `scripts/guarded-roots.mjs` `scripts/emit-mcp-policy.mjs` `scripts/gate-fat-tools.mjs` `scripts/hooks-union.mjs` `scripts/pipeline.mjs` `scripts/grunt-job.mjs` `scripts/parse-need.mjs` `scripts/persist-handoff.mjs` `scripts/persist-implementation.mjs` `scripts/persist-tmp.mjs` `scripts/persist-plan.mjs` `scripts/purge-global-mcps.mjs` `scripts/scrub-spawn-prompt.mjs` `scripts/scrub-text-lib.mjs` `scripts/sync-global-settings.mjs` `scripts/browser.mjs` `scripts/speak.mjs` `scripts/listen.mjs` `scripts/google-workspace.mjs` `scripts/interactive.mjs` `scripts/prompt.mjs` `scripts/setup.mjs` `scripts/doctor.mjs` `scripts/skill-conflicts.mjs` `scripts/scrub-text` `.rulesync` `.grok` `.codex` `.claude` `.agents` `AGENTS.md` `CLAUDE.md` `.mcp.json` `README.md` `LICENSE` `CHANGELOG.md`
350
351
 
351
352
  No `scripts/*.test.ts` `scripts/fixtures/` `docs/` `coverage/` `vitest.config.ts` in `files`. `cli` dir ships whole (includes `cli/*.test.ts`).
352
353
 
package/cli/grunt.mjs CHANGED
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"
2
2
  import { readFileSync } from "node:fs"
3
3
  import path from "node:path"
4
4
  import { fileURLToPath } from "node:url"
5
- import { destAlreadyInited, init, RESERVED_SKILLS, shouldAutoSkipGlobals, toGruntScriptName } from "./init.mjs"
5
+ import { destAlreadyInited, init, RESERVED_SKILLS, toGruntScriptName } from "./init.mjs"
6
6
  import { confirm, isInteractive, select, spinner } from "./prompt.mjs"
7
7
 
8
8
  const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
@@ -105,10 +105,12 @@ function bindSpinner() {
105
105
  const spin = spinner()
106
106
  return (name, action) => {
107
107
  if (action === "start") spin.start(name)
108
- else if (action === "stop") spin.stop()
108
+ else if (action === "stop") spin.stop(name)
109
109
  }
110
110
  }
111
111
 
112
+ export const APPLY_GLOBALS_CONFIRM = "Apply global prompt optimizations? (recommended)"
113
+
112
114
  async function runInit(cwd, { skipGlobals, interactive }) {
113
115
  if (!interactive) {
114
116
  init(cwd, { skipGlobals })
@@ -121,10 +123,9 @@ async function runInit(cwd, { skipGlobals, interactive }) {
121
123
  })
122
124
  if (!again) return
123
125
  }
124
- const autoSkip = skipGlobals || shouldAutoSkipGlobals(cwd)
125
126
  const applyGlobals = await confirm({
126
- message: "Apply globals?",
127
- initialValue: !autoSkip,
127
+ message: APPLY_GLOBALS_CONFIRM,
128
+ initialValue: !skipGlobals,
128
129
  })
129
130
  init(cwd, {
130
131
  skipGlobals: !applyGlobals,
package/cli/grunt.test.ts CHANGED
@@ -35,7 +35,7 @@ vi.mock("./prompt.mjs", () => ({
35
35
  bailIfCancel,
36
36
  }));
37
37
 
38
- import { parseArgv, start } from "./grunt.mjs";
38
+ import { APPLY_GLOBALS_CONFIRM, parseArgv, start } from "./grunt.mjs";
39
39
 
40
40
  const USAGE = `Usage: grunt [command]
41
41
 
@@ -320,6 +320,10 @@ describe("start", () => {
320
320
  ]);
321
321
  expect(init).toHaveBeenCalled();
322
322
  expect(isInteractive).toHaveBeenCalledOnce();
323
+ expect(confirm).toHaveBeenCalledWith({
324
+ message: APPLY_GLOBALS_CONFIRM,
325
+ initialValue: true,
326
+ });
323
327
  });
324
328
 
325
329
  it("TTY menu generate", async () => {
@@ -393,9 +397,12 @@ describe("start", () => {
393
397
  await start();
394
398
  expect(confirm).toHaveBeenCalled();
395
399
  const globalsConfirm = confirm.mock.calls.find(
396
- (c) => String((c[0] as { message?: string }).message).includes("globals"),
400
+ (c) => (c[0] as { message?: string }).message === APPLY_GLOBALS_CONFIRM,
397
401
  );
398
- expect(globalsConfirm?.[0]).toMatchObject({ initialValue: false });
402
+ expect(globalsConfirm?.[0]).toMatchObject({
403
+ message: APPLY_GLOBALS_CONFIRM,
404
+ initialValue: true,
405
+ });
399
406
  expect(init).toHaveBeenCalledWith(
400
407
  process.cwd(),
401
408
  expect.objectContaining({
@@ -406,8 +413,9 @@ describe("start", () => {
406
413
  );
407
414
  expect(isInteractive).toHaveBeenCalledOnce();
408
415
  expect(spin.start).toHaveBeenCalledWith("merge");
409
- expect(spin.stop).toHaveBeenCalled();
416
+ expect(spin.stop).toHaveBeenCalledWith("merge");
410
417
  expect(spin.start).toHaveBeenCalledWith("install");
418
+ expect(spin.stop).toHaveBeenCalledWith("install");
411
419
  });
412
420
 
413
421
  it("TTY init --skip-globals default confirm false maps to skip", async () => {
@@ -415,6 +423,10 @@ describe("start", () => {
415
423
  confirm.mockResolvedValue(false);
416
424
  process.argv = ["node", "grunt", "init", "--skip-globals"];
417
425
  await start();
426
+ expect(confirm).toHaveBeenCalledWith({
427
+ message: APPLY_GLOBALS_CONFIRM,
428
+ initialValue: false,
429
+ });
418
430
  expect(init).toHaveBeenCalledWith(
419
431
  process.cwd(),
420
432
  expect.objectContaining({
package/cli/init.mjs CHANGED
@@ -8,14 +8,40 @@ import {
8
8
  formatSkillConflictWarn,
9
9
  listSkillDirNames,
10
10
  } from "../scripts/skill-conflicts.mjs"
11
+ import {
12
+ SENTINEL_BEGIN,
13
+ extractGruntBody,
14
+ writeMergedGuardedFile,
15
+ snapshotGuardedRoots,
16
+ remergeGuardedRoots,
17
+ withGuardedCheckInteriors,
18
+ } from "../scripts/guarded-md.mjs"
19
+
20
+ export {
21
+ GUARDED_ROOT_FILES,
22
+ SENTINEL_BEGIN,
23
+ SENTINEL_END,
24
+ MAX_GUARDED_MARKDOWN_BYTES,
25
+ extractUserMarkdown,
26
+ extractGruntBody,
27
+ composeGuardedMarkdown,
28
+ mergeGuardedContent,
29
+ writeMergedGuardedFile,
30
+ guardedMarkdownDrift,
31
+ snapshotGuardedRoots,
32
+ remergeGuardedRoots,
33
+ healGuardedRootFile,
34
+ withGuardedCheckInteriors,
35
+ } from "../scripts/guarded-md.mjs"
11
36
 
12
37
  const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
13
38
 
14
- const PRODUCT_SCRIPTS = [
39
+ export const PRODUCT_SCRIPTS = [
15
40
  "check-globals.mjs",
16
41
  "emit-agent-shell-tools.mjs",
17
42
  "emit-gemini.mjs",
18
43
  "emit-maps.mjs",
44
+ "guarded-md.mjs",
19
45
  "guarded-roots.mjs",
20
46
  "emit-mcp-policy.mjs",
21
47
  "gate-fat-tools.mjs",
@@ -35,6 +61,8 @@ const PRODUCT_SCRIPTS = [
35
61
  "speak.mjs",
36
62
  "listen.mjs",
37
63
  "google-workspace.mjs",
64
+ "interactive.mjs",
65
+ "prompt.mjs",
38
66
  "setup.mjs",
39
67
  "doctor.mjs",
40
68
  "skill-conflicts.mjs",
@@ -88,7 +116,6 @@ const AGENT_MIRROR_DIRS = [
88
116
  [".codex", "agents"],
89
117
  ]
90
118
  const GUARDED_MD_FILES = ["AGENTS.md", "CLAUDE.md"]
91
- export const GUARDED_ROOT_FILES = ["AGENTS.md", "CLAUDE.md", "GEMINI.md"]
92
119
  // .mcp.json is intentionally not copied here: scripts/emit-mcp-policy.mjs
93
120
  // (run via consumer `grunt:rulesync:generate`, see mergeClaudeSettings ordering) owns and
94
121
  // merges it, so a plain-copy would just be clobbered/redundant.
@@ -104,16 +131,6 @@ export const LAUNCH_SCRIPTS = {
104
131
  grok: "grok --yolo",
105
132
  }
106
133
  const OWNED_HOOK_FILES = ["scrub-spawn-prompt.mjs", "gate-fat-tools.mjs", "orchestrate-parent.js"]
107
- export const SENTINEL_BEGIN = "<!-- grunt:begin -->"
108
- export const SENTINEL_END = "<!-- grunt:end -->"
109
- export const MAX_GUARDED_MARKDOWN_BYTES = 2 * 1024 * 1024
110
- const GRUNT_REGION_RE =
111
- /(?:^|(?<=\n)|(?<=\r\n))[ \t]*<!-- grunt:begin -->[ \t]*\r?\n?[\s\S]*?(?:^|(?<=\n)|(?<=\r\n))[ \t]*<!-- grunt:end -->[ \t]*\r?\n?/g
112
- const GRUNT_INTERIOR_RE =
113
- /(?:^|(?<=\n)|(?<=\r\n))[ \t]*<!-- grunt:begin -->[ \t]*\r?\n?([\s\S]*?)(?:^|(?<=\n)|(?<=\r\n))[ \t]*<!-- grunt:end -->/g
114
- const ORPHAN_END_RE = /(?:^|\r?\n)[ \t]*<!-- grunt:end -->[ \t]*(?=\r?\n|$)|<!-- grunt:end -->/g
115
- const BEGIN_LINE_RE = /^[ \t]*<!-- grunt:begin -->[ \t]*$/
116
- const END_LINE_RE = /^[ \t]*<!-- grunt:end -->[ \t]*$/
117
134
 
118
135
  function sortKeys(obj) {
119
136
  return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]]))
@@ -199,120 +216,6 @@ export function shouldAutoSkipGlobals(dest) {
199
216
  return destHasGruntSentinel(dest)
200
217
  }
201
218
 
202
- function detectNewline(text) {
203
- return String(text).includes("\r\n") ? "\r\n" : "\n"
204
- }
205
-
206
- function toNewline(text, nl) {
207
- return String(text).replace(/\r\n/g, "\n").replace(/\n/g, nl)
208
- }
209
-
210
- function trimTrailingNewlines(text) {
211
- return String(text).replace(/(?:\r?\n)+$/, "")
212
- }
213
-
214
- function normEq(a, b) {
215
- return trimTrailingNewlines(String(a).replace(/\r\n/g, "\n")) ===
216
- trimTrailingNewlines(String(b).replace(/\r\n/g, "\n"))
217
- }
218
-
219
- function cloneRe(re) {
220
- return new RegExp(re.source, re.flags)
221
- }
222
-
223
- function inspectGuardedBuffer(buf) {
224
- if (buf.length > MAX_GUARDED_MARKDOWN_BYTES) return "huge"
225
- if (buf.includes(0)) return "binary"
226
- return null
227
- }
228
-
229
- export function extractUserMarkdown(text) {
230
- let user = String(text).replace(cloneRe(GRUNT_REGION_RE), "")
231
- const i = user.indexOf(SENTINEL_BEGIN)
232
- if (i >= 0) user = user.slice(0, i)
233
- user = user.replace(cloneRe(ORPHAN_END_RE), "")
234
- return userRemainderIsBlank(user) ? "" : user
235
- }
236
-
237
- function unwrapStackedMarkers(text) {
238
- const lines = String(text).split(/\r?\n/)
239
- let start = 0
240
- let end = lines.length
241
- while (start < end && BEGIN_LINE_RE.test(lines[start])) start++
242
- while (end > start && lines[end - 1] === "") end--
243
- while (end > start && END_LINE_RE.test(lines[end - 1])) {
244
- end--
245
- while (end > start && lines[end - 1] === "") end--
246
- }
247
- return lines.slice(start, end).join("\n")
248
- }
249
-
250
- export function extractGruntBody(text) {
251
- const src = String(text)
252
- const parts = []
253
- const re = cloneRe(GRUNT_INTERIOR_RE)
254
- let m
255
- while ((m = re.exec(src))) {
256
- parts.push(trimTrailingNewlines(unwrapStackedMarkers(m[1])))
257
- }
258
- if (parts.length) return parts.join("\n")
259
- const i = src.indexOf(SENTINEL_BEGIN)
260
- if (i >= 0) {
261
- return trimTrailingNewlines(
262
- unwrapStackedMarkers(src.slice(i + SENTINEL_BEGIN.length).replace(/^[ \t]*\r?\n/, "")),
263
- )
264
- }
265
- if (!src.includes(SENTINEL_END)) return null
266
- return trimTrailingNewlines(unwrapStackedMarkers(src))
267
- }
268
-
269
- function userRemainderIsBlank(user) {
270
- return String(user).replace(/\r\n/g, "\n").replace(/\n/g, "") === ""
271
- }
272
-
273
- export function composeGuardedMarkdown(gruntBody, userRemainder, nl = "\n") {
274
- const raw = extractGruntBody(gruntBody) ?? gruntBody
275
- const body = toNewline(trimTrailingNewlines(raw), nl)
276
- const block = `${SENTINEL_BEGIN}${nl}${body}${nl}${SENTINEL_END}${nl}`
277
- const user = extractUserMarkdown(userRemainder == null ? "" : String(userRemainder))
278
- if (!user || userRemainderIsBlank(user)) return block
279
- return block + toNewline(user, nl)
280
- }
281
-
282
- export function mergeGuardedContent(existingText, gruntBody) {
283
- const existing = existingText == null ? "" : String(existingText)
284
- const nl = existing.includes("\r\n") || String(gruntBody).includes("\r\n")
285
- ? detectNewline(existing || gruntBody)
286
- : "\n"
287
- if (!existing) return composeGuardedMarkdown(gruntBody, "", nl)
288
- const hadSentinel = existing.includes(SENTINEL_BEGIN) && existing.includes(SENTINEL_END)
289
- const user = extractUserMarkdown(existing)
290
- if (!hadSentinel && normEq(existing, gruntBody)) {
291
- return composeGuardedMarkdown(gruntBody, "", nl)
292
- }
293
- return composeGuardedMarkdown(gruntBody, user, nl)
294
- }
295
-
296
- export function writeMergedGuardedFile(destPath, gruntBody) {
297
- if (!fs.existsSync(destPath)) {
298
- fs.writeFileSync(destPath, mergeGuardedContent("", gruntBody))
299
- return
300
- }
301
- const buf = fs.readFileSync(destPath)
302
- if (inspectGuardedBuffer(buf)) return "aborted-unsafe"
303
- fs.writeFileSync(destPath, mergeGuardedContent(buf.toString("utf8"), gruntBody))
304
- }
305
-
306
- export function guardedMarkdownDrift(destPath, gruntBody) {
307
- if (!fs.existsSync(destPath)) return true
308
- const buf = fs.readFileSync(destPath)
309
- if (inspectGuardedBuffer(buf)) return true
310
- const text = buf.toString("utf8")
311
- const interior = extractGruntBody(text)
312
- if (interior == null) return !normEq(text, gruntBody)
313
- return !normEq(interior, gruntBody)
314
- }
315
-
316
219
  export function mergeGuardedMarkdown(dest, pkgRoot, file, { alreadyInited = false } = {}) {
317
220
  void alreadyInited
318
221
  const src = path.join(pkgRoot, file)
@@ -322,104 +225,6 @@ export function mergeGuardedMarkdown(dest, pkgRoot, file, { alreadyInited = fals
322
225
  return writeMergedGuardedFile(d, extractGruntBody(srcText) ?? srcText)
323
226
  }
324
227
 
325
- export function snapshotGuardedRoots(root) {
326
- const snap = {}
327
- for (const file of GUARDED_ROOT_FILES) {
328
- const p = path.join(root, file)
329
- if (!fs.existsSync(p)) continue
330
- const buf = fs.readFileSync(p)
331
- const unsafe = inspectGuardedBuffer(buf)
332
- if (unsafe) {
333
- snap[file] = { unsafe, rawBuf: buf }
334
- continue
335
- }
336
- const raw = buf.toString("utf8")
337
- snap[file] = { raw, user: extractUserMarkdown(raw) }
338
- }
339
- return snap
340
- }
341
-
342
- export function remergeGuardedRoots(root, snap) {
343
- for (const file of GUARDED_ROOT_FILES) {
344
- const p = path.join(root, file)
345
- const rec = snap[file]
346
- if (rec?.unsafe) {
347
- fs.writeFileSync(p, rec.rawBuf)
348
- continue
349
- }
350
- if (!fs.existsSync(p)) {
351
- if (rec?.raw != null) fs.writeFileSync(p, rec.raw)
352
- continue
353
- }
354
- const buf = fs.readFileSync(p)
355
- if (inspectGuardedBuffer(buf)) {
356
- if (rec?.raw != null) fs.writeFileSync(p, rec.raw)
357
- continue
358
- }
359
- const current = buf.toString("utf8")
360
- if (rec && current === rec.raw) continue
361
- const gruntBody = extractGruntBody(current) ?? current
362
- const nl = detectNewline((rec && rec.raw) || current)
363
- let user = rec ? rec.user : extractUserMarkdown(current)
364
- if (rec && !rec.raw.includes(SENTINEL_BEGIN) && normEq(rec.raw, gruntBody)) user = ""
365
- if (!rec && extractGruntBody(current) == null && normEq(current, gruntBody)) user = ""
366
- fs.writeFileSync(p, composeGuardedMarkdown(gruntBody, user, nl))
367
- }
368
- }
369
-
370
- export function healGuardedRootFile(root, file, snap) {
371
- if (!GUARDED_ROOT_FILES.includes(file)) return
372
- const p = path.join(root, file)
373
- const rec = snap[file]
374
- if (!fs.existsSync(p)) return
375
- if (rec?.unsafe) {
376
- fs.writeFileSync(p, rec.rawBuf)
377
- return
378
- }
379
- const buf = fs.readFileSync(p)
380
- if (inspectGuardedBuffer(buf)) {
381
- if (rec?.raw != null) fs.writeFileSync(p, rec.raw)
382
- return
383
- }
384
- const current = buf.toString("utf8")
385
- const interior = extractGruntBody(current)
386
- if (interior != null) {
387
- snap[file] = { raw: current, user: extractUserMarkdown(current) }
388
- return
389
- }
390
- if (rec && current === rec.raw) return
391
- const gruntBody = current
392
- const nl = detectNewline((rec && rec.raw) || current)
393
- let user = rec ? rec.user : extractUserMarkdown(current)
394
- if (rec && !rec.raw.includes(SENTINEL_BEGIN) && normEq(rec.raw, gruntBody)) user = ""
395
- if (!rec && normEq(current, gruntBody)) user = ""
396
- const out = composeGuardedMarkdown(gruntBody, user, nl)
397
- fs.writeFileSync(p, out)
398
- snap[file] = { raw: out, user }
399
- }
400
-
401
- export function withGuardedCheckInteriors(root, fn) {
402
- const snap = snapshotGuardedRoots(root)
403
- try {
404
- for (const file of GUARDED_ROOT_FILES) {
405
- const rec = snap[file]
406
- if (!rec || rec.unsafe) continue
407
- const body = extractGruntBody(rec.raw)
408
- if (body == null) continue
409
- const nl = detectNewline(rec.raw)
410
- fs.writeFileSync(path.join(root, file), `${toNewline(trimTrailingNewlines(body), nl)}${nl}`)
411
- }
412
- return fn()
413
- } finally {
414
- for (const file of GUARDED_ROOT_FILES) {
415
- const rec = snap[file]
416
- if (!rec) continue
417
- if (rec.unsafe) fs.writeFileSync(path.join(root, file), rec.rawBuf)
418
- else fs.writeFileSync(path.join(root, file), rec.raw)
419
- }
420
- }
421
- }
422
-
423
228
  export function mergeClaudeSettings(destRoot, pkgRoot) {
424
229
  const srcPath = path.join(pkgRoot, ".claude", "settings.json")
425
230
  const destPath = path.join(destRoot, ".claude", "settings.json")
@@ -576,6 +381,8 @@ export function mergePackageJson(dest, pkgRoot) {
576
381
  destPkg.devDependencies = { ...(destPkg.devDependencies || {}) }
577
382
  destPkg.devDependencies["smol-toml"] = srcPkg.devDependencies["smol-toml"]
578
383
  destPkg.devDependencies["rulesync"] = srcPkg.devDependencies["rulesync"]
384
+ const clack = srcPkg.dependencies?.["@clack/prompts"]
385
+ if (clack) destPkg.devDependencies["@clack/prompts"] = clack
579
386
  destPkg.scripts = sortKeys(destPkg.scripts)
580
387
  destPkg.devDependencies = sortKeys(destPkg.devDependencies)
581
388
  fs.writeFileSync(destPath, `${JSON.stringify(destPkg, null, 2)}\n`)
package/cli/init.test.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import fs from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
@@ -5,6 +6,7 @@ import { fileURLToPath } from "node:url";
5
6
  import { afterEach, describe, expect, it, vi } from "vitest";
6
7
  import {
7
8
  MAX_GUARDED_MARKDOWN_BYTES,
9
+ PRODUCT_SCRIPTS,
8
10
  composeGuardedMarkdown,
9
11
  destAlreadyInited,
10
12
  extractGruntBody,
@@ -52,34 +54,8 @@ function tmp(prefix: string) {
52
54
 
53
55
  const COPY_DIRS = [".rulesync", ".grok", ".codex", ".claude", ".agents"];
54
56
  const GUARDED_MD_FILES = ["AGENTS.md", "CLAUDE.md"];
55
- const PRODUCT_FILES = [
56
- "check-globals.mjs",
57
- "emit-agent-shell-tools.mjs",
58
- "emit-gemini.mjs",
59
- "emit-maps.mjs",
60
- "guarded-roots.mjs",
61
- "emit-mcp-policy.mjs",
62
- "gate-fat-tools.mjs",
63
- "hooks-union.mjs",
64
- "pipeline.mjs",
65
- "grunt-job.mjs",
66
- "parse-need.mjs",
67
- "persist-handoff.mjs",
68
- "persist-implementation.mjs",
69
- "persist-tmp.mjs",
70
- "persist-plan.mjs",
71
- "purge-global-mcps.mjs",
72
- "scrub-spawn-prompt.mjs",
73
- "scrub-text-lib.mjs",
74
- "sync-global-settings.mjs",
75
- "browser.mjs",
76
- "speak.mjs",
77
- "listen.mjs",
78
- "google-workspace.mjs",
79
- "setup.mjs",
80
- "doctor.mjs",
81
- "skill-conflicts.mjs",
82
- ];
57
+ const PRODUCT_FILES = PRODUCT_SCRIPTS.filter((name) => name !== "scrub-text");
58
+ const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
83
59
 
84
60
  const SRC_CLAUDE_SETTINGS = {
85
61
  permissions: {
@@ -1996,3 +1972,98 @@ describe("destAlreadyInited / shouldAutoSkipGlobals", () => {
1996
1972
  });
1997
1973
  });
1998
1974
 
1975
+ function spawnDest(dest: string, args: string[], env: NodeJS.ProcessEnv = process.env) {
1976
+ return spawnSync(process.execPath, args, {
1977
+ cwd: dest,
1978
+ encoding: "utf8",
1979
+ env,
1980
+ });
1981
+ }
1982
+
1983
+ const PARENT_IMPORT_RE =
1984
+ /(?:from\s+|import\s*\(\s*)["'`](\.\.[^"'`]*)["'`]/g;
1985
+ const RELATIVE_IMPORT_RE =
1986
+ /(?:from\s+|import\s*\(\s*)["'`](\.[^"'`]+)["'`]/g;
1987
+
1988
+ function relativeSpecs(text: string, re: RegExp) {
1989
+ const out: string[] = [];
1990
+ const copy = new RegExp(re.source, "g");
1991
+ let m: RegExpExecArray | null;
1992
+ while ((m = copy.exec(text))) out.push(m[1]);
1993
+ return out;
1994
+ }
1995
+
1996
+ describe("product scripts are consumer-safe", () => {
1997
+ it("package.json files lists every PRODUCT_SCRIPTS entry", () => {
1998
+ const pkg = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8"));
1999
+ for (const name of PRODUCT_SCRIPTS) {
2000
+ expect(pkg.files).toContain(`scripts/${name}`);
2001
+ }
2002
+ });
2003
+
2004
+ it("do not parent-import (from or import()) — init copies scripts/ only", () => {
2005
+ for (const name of PRODUCT_FILES) {
2006
+ const text = fs.readFileSync(path.join(PKG_ROOT, "scripts", name), "utf8");
2007
+ expect(relativeSpecs(text, PARENT_IMPORT_RE), name).toEqual([]);
2008
+ }
2009
+ });
2010
+
2011
+ it("repro: dest node ./scripts/guarded-roots.mjs generate misses ../cli/init.mjs", () => {
2012
+ const dest = tmp("repro-cli-miss-");
2013
+ fs.mkdirSync(path.join(dest, "scripts"));
2014
+ fs.writeFileSync(
2015
+ path.join(dest, "scripts", "broken.mjs"),
2016
+ 'import { snapshotGuardedRoots } from "../cli/init.mjs";\n',
2017
+ );
2018
+ const r = spawnDest(dest, ["./scripts/broken.mjs", "generate"]);
2019
+ expect(r.status).not.toBe(0);
2020
+ expect(r.stderr).toMatch(/ERR_MODULE_NOT_FOUND/);
2021
+ expect(r.stderr).toMatch(/cli\/init\.mjs/);
2022
+ });
2023
+
2024
+ it("after init, copied scripts' relative imports exist and generate loads", () => {
2025
+ const dest = tmp("init-generate-");
2026
+ init(dest, {
2027
+ pkgRoot: PKG_ROOT,
2028
+ execFileSync: vi.fn(),
2029
+ skipGlobals: true,
2030
+ applyGlobals: false,
2031
+ });
2032
+ expect(fs.existsSync(path.join(dest, "cli"))).toBe(false);
2033
+ expect(fs.existsSync(path.join(dest, "scripts", "guarded-roots.mjs"))).toBe(true);
2034
+
2035
+ for (const name of PRODUCT_FILES) {
2036
+ const file = path.join(dest, "scripts", name);
2037
+ expect(fs.existsSync(file), name).toBe(true);
2038
+ const text = fs.readFileSync(file, "utf8");
2039
+ for (const spec of relativeSpecs(text, RELATIVE_IMPORT_RE)) {
2040
+ const resolved = path.resolve(path.dirname(file), spec);
2041
+ expect(fs.existsSync(resolved), `${name} -> ${spec}`).toBe(true);
2042
+ }
2043
+ }
2044
+
2045
+ const loaded = spawnDest(dest, ["./scripts/guarded-roots.mjs"]);
2046
+ expect(loaded.status).toBe(1);
2047
+ expect(loaded.stderr).toBe("usage: guarded-roots.mjs generate|check|watch\n");
2048
+ expect(loaded.stderr).not.toMatch(/ERR_MODULE_NOT_FOUND/);
2049
+
2050
+ const gen = spawnDest(dest, ["./scripts/guarded-roots.mjs", "generate"], {
2051
+ ...process.env,
2052
+ PATH: ["/usr/bin", "/bin"].join(path.delimiter),
2053
+ });
2054
+ expect(gen.stderr).not.toMatch(/ERR_MODULE_NOT_FOUND/);
2055
+ expect(gen.stderr).not.toMatch(/cli\/init\.mjs/);
2056
+ expect(`${gen.stderr}${gen.stdout}`).toMatch(/rulesync|spawn|not found|ENOENT/i);
2057
+
2058
+ for (const name of ["guarded-roots.mjs", "emit-gemini.mjs", "setup.mjs"]) {
2059
+ const r = spawnDest(dest, [
2060
+ "--input-type=module",
2061
+ "-e",
2062
+ `import(${JSON.stringify(`./scripts/${name}`)})`,
2063
+ ]);
2064
+ expect(r.status, `${name} load\n${r.stderr}`).toBe(0);
2065
+ expect(r.stderr, name).not.toMatch(/ERR_MODULE_NOT_FOUND/);
2066
+ }
2067
+ });
2068
+ });
2069
+
package/cli/prompt.mjs CHANGED
@@ -1,44 +1,9 @@
1
- import * as clack from "@clack/prompts"
2
-
3
- export function isInteractive({
4
- argv = process.argv.slice(2),
5
- env = process.env,
6
- stdin = process.stdin,
7
- stdout = process.stdout,
8
- } = {}) {
9
- if (!stdin?.isTTY || !stdout?.isTTY) return false
10
- const ci = env.CI
11
- if (ci != null && ci !== "" && ci !== "0" && ci !== "false") return false
12
- for (const a of argv) {
13
- if (a === "--yes" || a === "-y" || a === "--non-interactive") return false
14
- }
15
- return true
16
- }
17
-
18
- export function bailIfCancel(value) {
19
- if (clack.isCancel(value)) {
20
- clack.cancel("Aborted")
21
- process.exit(0)
22
- }
23
- return value
24
- }
25
-
26
- export async function select(opts) {
27
- return bailIfCancel(await clack.select(opts))
28
- }
29
-
30
- export async function confirm(opts) {
31
- return bailIfCancel(await clack.confirm(opts))
32
- }
33
-
34
- export async function text(opts) {
35
- return bailIfCancel(await clack.text(opts))
36
- }
37
-
38
- export async function password(opts) {
39
- return bailIfCancel(await clack.password(opts))
40
- }
41
-
42
- export function spinner() {
43
- return clack.spinner()
44
- }
1
+ export {
2
+ bailIfCancel,
3
+ confirm,
4
+ isInteractive,
5
+ password,
6
+ select,
7
+ spinner,
8
+ text,
9
+ } from "../scripts/prompt.mjs"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovrozagar/grunt",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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",
@@ -28,6 +28,7 @@
28
28
  "scripts/emit-agent-shell-tools.mjs",
29
29
  "scripts/emit-gemini.mjs",
30
30
  "scripts/emit-maps.mjs",
31
+ "scripts/guarded-md.mjs",
31
32
  "scripts/guarded-roots.mjs",
32
33
  "scripts/emit-mcp-policy.mjs",
33
34
  "scripts/gate-fat-tools.mjs",
@@ -47,6 +48,8 @@
47
48
  "scripts/speak.mjs",
48
49
  "scripts/listen.mjs",
49
50
  "scripts/google-workspace.mjs",
51
+ "scripts/interactive.mjs",
52
+ "scripts/prompt.mjs",
50
53
  "scripts/setup.mjs",
51
54
  "scripts/doctor.mjs",
52
55
  "scripts/skill-conflicts.mjs",
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /** Session browser rail: nav|snap|click|fill|scroll|wait|hover|select|shot|pdf|stop|doctor|ensure. Lightpanda default. */
3
- import { spawn } from "node:child_process";
3
+ import { spawn, spawnSync } from "node:child_process";
4
4
  import crypto from "node:crypto";
5
5
  import fs from "node:fs";
6
6
  import http from "node:http";
@@ -137,6 +137,21 @@ function alive(pid) {
137
137
 
138
138
  function reap(pid) {
139
139
  if (!pid) return;
140
+ if (process.platform === "win32") {
141
+ try {
142
+ process.kill(pid);
143
+ } catch {
144
+ /* ignore */
145
+ }
146
+ if (alive(pid)) {
147
+ spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], {
148
+ stdio: "ignore",
149
+ timeout: 1500,
150
+ windowsHide: true,
151
+ });
152
+ }
153
+ return;
154
+ }
140
155
  try {
141
156
  process.kill(-pid, "SIGTERM");
142
157
  } catch {
@@ -480,6 +495,19 @@ function engineBin(engine, bins) {
480
495
  return bins.chromium;
481
496
  }
482
497
 
498
+ function spawnEngine(bin, args, opts) {
499
+ if (process.platform === "win32" && /\.(cmd|bat)$/i.test(bin)) {
500
+ const mjs = `${String(bin).replace(/\.(cmd|bat)$/i, "")}.mjs`;
501
+ try {
502
+ fs.accessSync(mjs, fs.constants.F_OK);
503
+ return spawn(process.execPath, [mjs, ...args], opts);
504
+ } catch {
505
+ return spawn(bin, args, { ...opts, shell: true });
506
+ }
507
+ }
508
+ return spawn(bin, args, opts);
509
+ }
510
+
483
511
  function spawnArgs(engine, port, profile) {
484
512
  if (engine === "lightpanda") return ["serve", "--host", "127.0.0.1", "--port", String(port)];
485
513
  return [
@@ -501,7 +529,7 @@ async function launchEngine(engine, ctx) {
501
529
  const profile = path.join(dir, "profile");
502
530
  fs.mkdirSync(profile, { recursive: true });
503
531
  const port = await freePort();
504
- const child = spawn(bin, spawnArgs(engine, port, profile), {
532
+ const child = spawnEngine(bin, spawnArgs(engine, port, profile), {
505
533
  detached: true,
506
534
  stdio: ["ignore", "pipe", "pipe"],
507
535
  env: ctx.env,
@@ -36,7 +36,7 @@ export function checkGlobals({ home, workspaceRoot } = {}) {
36
36
  if (!homeDir) {
37
37
  return { ok: false, error: `HOME required; ${APPLY_HINT}` };
38
38
  }
39
- const homeConfig = path.join(homeDir, HOME_REL);
39
+ const homeConfig = path.resolve(homeDir, HOME_REL);
40
40
  const homeParsed = parseTomlFile(homeConfig, "home ~/.grok/config.toml");
41
41
  if (!homeParsed.ok) {
42
42
  if (homeParsed.missing) {
@@ -54,7 +54,10 @@ export function checkGlobals({ home, workspaceRoot } = {}) {
54
54
  };
55
55
  }
56
56
 
57
- const projectConfig = path.join(ws, PROJECT_REL);
57
+ const projectConfig = path.resolve(ws, PROJECT_REL);
58
+ if (projectConfig === homeConfig) {
59
+ return { ok: true };
60
+ }
58
61
  const projectParsed = parseTomlFile(projectConfig, "project .grok/config.toml");
59
62
  if (!projectParsed.ok) {
60
63
  if (projectParsed.missing) {
@@ -42,20 +42,32 @@ export function nodeMajor(version) {
42
42
  return m ? Number(m[1]) : 0;
43
43
  }
44
44
 
45
+ function winShim(bin) {
46
+ return process.platform === "win32" && /\.(cmd|bat)$/i.test(String(bin || ""));
47
+ }
48
+
45
49
  export function whichBin(name, pathEnv = process.env.PATH, platform = process.platform) {
46
50
  if (!name) return "";
51
+ const hostWin = process.platform === "win32";
47
52
  const delim =
48
- platform === "win32" && String(pathEnv || "").includes(";") ? ";" : path.delimiter;
53
+ (platform === "win32" || hostWin) && String(pathEnv || "").includes(";")
54
+ ? ";"
55
+ : path.delimiter;
49
56
  const dirs = String(pathEnv || "").split(delim);
50
- const names = [name];
51
- if (platform === "win32" && !path.extname(name)) {
52
- const pathext = String(process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM");
53
- for (const raw of pathext.split(";")) {
54
- if (!raw) continue;
55
- names.push(name + raw);
56
- names.push(name + raw.toLowerCase());
57
+ const names = [];
58
+ if ((hostWin || platform === "win32") && !path.extname(name)) {
59
+ if (hostWin) {
60
+ const pathext = String(process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM");
61
+ for (const raw of pathext.split(";")) {
62
+ if (!raw) continue;
63
+ names.push(name + raw.toLowerCase());
64
+ names.push(name + raw);
65
+ }
57
66
  }
58
67
  names.push(`${name}.exe`);
68
+ names.push(name);
69
+ } else {
70
+ names.push(name);
59
71
  }
60
72
  const uniq = [...new Set(names)];
61
73
  for (const dir of dirs) {
@@ -66,7 +78,7 @@ export function whichBin(name, pathEnv = process.env.PATH, platform = process.pl
66
78
  fs.accessSync(candidate, fs.constants.X_OK);
67
79
  return candidate;
68
80
  } catch {
69
- if (platform === "win32") {
81
+ if (hostWin) {
70
82
  try {
71
83
  fs.accessSync(candidate, fs.constants.F_OK);
72
84
  return candidate;
@@ -97,6 +109,7 @@ function readNodeVersion(bin) {
97
109
  encoding: "utf8",
98
110
  timeout: 8000,
99
111
  stdio: ["ignore", "pipe", "pipe"],
112
+ shell: winShim(bin),
100
113
  }),
101
114
  ).trim();
102
115
  } catch {
@@ -3,7 +3,7 @@
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
- import { guardedMarkdownDrift, writeMergedGuardedFile } from "../cli/init.mjs";
6
+ import { guardedMarkdownDrift, writeMergedGuardedFile } from "./guarded-md.mjs";
7
7
 
8
8
  export const AGENT_IDS = ["orchestrator", "grunt"];
9
9
  const SSOT_REL = ".rulesync/subagents";
@@ -175,9 +175,14 @@ function isMcpToolRule(s) {
175
175
  return s === "MCPTool" || s.startsWith("MCPTool(");
176
176
  }
177
177
 
178
- function desiredGrok(disk, policy) {
178
+ export function isHomeGrokConfig(workspaceRoot, home) {
179
+ if (!workspaceRoot || !home) return false;
180
+ return path.resolve(workspaceRoot, GROK_REL) === path.resolve(home, GROK_REL);
181
+ }
182
+
183
+ function desiredGrok(disk, policy, { keepHomeTables = false } = {}) {
179
184
  const obj = isPlainObject(disk) ? { ...disk } : {};
180
- delete obj.features;
185
+ if (!keepHomeTables) delete obj.features;
181
186
  delete obj.permissions;
182
187
 
183
188
  const plugins = isPlainObject(obj.plugins) ? { ...obj.plugins } : {};
@@ -288,9 +293,9 @@ function geminiCheckOk(disk, desired) {
288
293
  return deepEqual(disk.mcpServers, desired.mcpServers);
289
294
  }
290
295
 
291
- function grokCheckOk(disk, desired, policy) {
296
+ function grokCheckOk(disk, desired, policy, { keepHomeTables = false } = {}) {
292
297
  if (!isPlainObject(disk)) return false;
293
- if (Object.prototype.hasOwnProperty.call(disk, "features")) return false;
298
+ if (!keepHomeTables && Object.prototype.hasOwnProperty.call(disk, "features")) return false;
294
299
  if (Array.isArray(disk.permissions)) return false;
295
300
  if (isPlainObject(disk.plugins) && Object.prototype.hasOwnProperty.call(disk.plugins, "deny_default")) {
296
301
  return false;
@@ -336,13 +341,14 @@ function writeOrCheck({ check, abs, nextText, diskRaw }) {
336
341
  return true;
337
342
  }
338
343
 
339
- export function emitMcpPolicy({ workspaceRoot, check = false } = {}) {
344
+ export function emitMcpPolicy({ workspaceRoot, check = false, home } = {}) {
340
345
  const ws = workspaceRoot || process.cwd();
341
346
  const policy = loadPolicy(ws);
342
347
  const valid = validatePolicy(policy);
343
348
  if (!valid.ok) {
344
349
  return { ok: false, check, error: valid.error };
345
350
  }
351
+ const keepHomeTables = isHomeGrokConfig(ws, home || process.env.HOME);
346
352
 
347
353
  const grokAbs = path.join(ws, GROK_REL);
348
354
  const claudeAbs = path.join(ws, CLAUDE_SETTINGS_REL);
@@ -365,7 +371,7 @@ export function emitMcpPolicy({ workspaceRoot, check = false } = {}) {
365
371
  const agentsDisk = agentsRaw == null ? null : parseJsonFile(agentsRaw, {});
366
372
  const geminiDisk = geminiRaw == null ? null : parseJsonFile(geminiRaw, {});
367
373
 
368
- const grokDesired = desiredGrok(grokDisk, policy);
374
+ const grokDesired = desiredGrok(grokDisk, policy, { keepHomeTables });
369
375
  const claudeDesired = desiredClaudeSettings(claudeDisk, policy);
370
376
  const mcpDesired = desiredMcpJson(policy);
371
377
  const codexDesired = desiredCodex(codexDisk, policy);
@@ -374,7 +380,7 @@ export function emitMcpPolicy({ workspaceRoot, check = false } = {}) {
374
380
 
375
381
  if (check) {
376
382
  const drift = [];
377
- if (grokRaw == null || !grokCheckOk(grokDisk, grokDesired, policy)) drift.push(GROK_REL);
383
+ if (grokRaw == null || !grokCheckOk(grokDisk, grokDesired, policy, { keepHomeTables })) drift.push(GROK_REL);
378
384
  if (claudeRaw == null || !claudeCheckOk(claudeDisk, claudeDesired)) drift.push(CLAUDE_SETTINGS_REL);
379
385
  if (mcpDisk == null || !deepEqual(mcpDisk, mcpDesired)) drift.push(MCP_JSON_REL);
380
386
  const codexOk =
@@ -0,0 +1,230 @@
1
+ /** Sentinel merge for AGENTS.md CLAUDE.md GEMINI.md. Copied into consumers. */
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export const GUARDED_ROOT_FILES = ["AGENTS.md", "CLAUDE.md", "GEMINI.md"];
6
+ export const SENTINEL_BEGIN = "<!-- grunt:begin -->";
7
+ export const SENTINEL_END = "<!-- grunt:end -->";
8
+ export const MAX_GUARDED_MARKDOWN_BYTES = 2 * 1024 * 1024;
9
+ const GRUNT_REGION_RE =
10
+ /(?:^|(?<=\n)|(?<=\r\n))[ \t]*<!-- grunt:begin -->[ \t]*\r?\n?[\s\S]*?(?:^|(?<=\n)|(?<=\r\n))[ \t]*<!-- grunt:end -->[ \t]*\r?\n?/g;
11
+ const GRUNT_INTERIOR_RE =
12
+ /(?:^|(?<=\n)|(?<=\r\n))[ \t]*<!-- grunt:begin -->[ \t]*\r?\n?([\s\S]*?)(?:^|(?<=\n)|(?<=\r\n))[ \t]*<!-- grunt:end -->/g;
13
+ const ORPHAN_END_RE = /(?:^|\r?\n)[ \t]*<!-- grunt:end -->[ \t]*(?=\r?\n|$)|<!-- grunt:end -->/g;
14
+ const BEGIN_LINE_RE = /^[ \t]*<!-- grunt:begin -->[ \t]*$/;
15
+ const END_LINE_RE = /^[ \t]*<!-- grunt:end -->[ \t]*$/;
16
+
17
+ function detectNewline(text) {
18
+ return String(text).includes("\r\n") ? "\r\n" : "\n";
19
+ }
20
+
21
+ function toNewline(text, nl) {
22
+ return String(text).replace(/\r\n/g, "\n").replace(/\n/g, nl);
23
+ }
24
+
25
+ function trimTrailingNewlines(text) {
26
+ return String(text).replace(/(?:\r?\n)+$/, "");
27
+ }
28
+
29
+ function normEq(a, b) {
30
+ return (
31
+ trimTrailingNewlines(String(a).replace(/\r\n/g, "\n")) ===
32
+ trimTrailingNewlines(String(b).replace(/\r\n/g, "\n"))
33
+ );
34
+ }
35
+
36
+ function cloneRe(re) {
37
+ return new RegExp(re.source, re.flags);
38
+ }
39
+
40
+ function inspectGuardedBuffer(buf) {
41
+ if (buf.length > MAX_GUARDED_MARKDOWN_BYTES) return "huge";
42
+ if (buf.includes(0)) return "binary";
43
+ return null;
44
+ }
45
+
46
+ export function extractUserMarkdown(text) {
47
+ let user = String(text).replace(cloneRe(GRUNT_REGION_RE), "");
48
+ const i = user.indexOf(SENTINEL_BEGIN);
49
+ if (i >= 0) user = user.slice(0, i);
50
+ user = user.replace(cloneRe(ORPHAN_END_RE), "");
51
+ return userRemainderIsBlank(user) ? "" : user;
52
+ }
53
+
54
+ function unwrapStackedMarkers(text) {
55
+ const lines = String(text).split(/\r?\n/);
56
+ let start = 0;
57
+ let end = lines.length;
58
+ while (start < end && BEGIN_LINE_RE.test(lines[start])) start++;
59
+ while (end > start && lines[end - 1] === "") end--;
60
+ while (end > start && END_LINE_RE.test(lines[end - 1])) {
61
+ end--;
62
+ while (end > start && lines[end - 1] === "") end--;
63
+ }
64
+ return lines.slice(start, end).join("\n");
65
+ }
66
+
67
+ export function extractGruntBody(text) {
68
+ const src = String(text);
69
+ const parts = [];
70
+ const re = cloneRe(GRUNT_INTERIOR_RE);
71
+ let m;
72
+ while ((m = re.exec(src))) {
73
+ parts.push(trimTrailingNewlines(unwrapStackedMarkers(m[1])));
74
+ }
75
+ if (parts.length) return parts.join("\n");
76
+ const i = src.indexOf(SENTINEL_BEGIN);
77
+ if (i >= 0) {
78
+ return trimTrailingNewlines(
79
+ unwrapStackedMarkers(src.slice(i + SENTINEL_BEGIN.length).replace(/^[ \t]*\r?\n/, "")),
80
+ );
81
+ }
82
+ if (!src.includes(SENTINEL_END)) return null;
83
+ return trimTrailingNewlines(unwrapStackedMarkers(src));
84
+ }
85
+
86
+ function userRemainderIsBlank(user) {
87
+ return String(user).replace(/\r\n/g, "\n").replace(/\n/g, "") === "";
88
+ }
89
+
90
+ export function composeGuardedMarkdown(gruntBody, userRemainder, nl = "\n") {
91
+ const raw = extractGruntBody(gruntBody) ?? gruntBody;
92
+ const body = toNewline(trimTrailingNewlines(raw), nl);
93
+ const block = `${SENTINEL_BEGIN}${nl}${body}${nl}${SENTINEL_END}${nl}`;
94
+ const user = extractUserMarkdown(userRemainder == null ? "" : String(userRemainder));
95
+ if (!user || userRemainderIsBlank(user)) return block;
96
+ return block + toNewline(user, nl);
97
+ }
98
+
99
+ export function mergeGuardedContent(existingText, gruntBody) {
100
+ const existing = existingText == null ? "" : String(existingText);
101
+ const nl =
102
+ existing.includes("\r\n") || String(gruntBody).includes("\r\n")
103
+ ? detectNewline(existing || gruntBody)
104
+ : "\n";
105
+ if (!existing) return composeGuardedMarkdown(gruntBody, "", nl);
106
+ const hadSentinel = existing.includes(SENTINEL_BEGIN) && existing.includes(SENTINEL_END);
107
+ const user = extractUserMarkdown(existing);
108
+ if (!hadSentinel && normEq(existing, gruntBody)) {
109
+ return composeGuardedMarkdown(gruntBody, "", nl);
110
+ }
111
+ return composeGuardedMarkdown(gruntBody, user, nl);
112
+ }
113
+
114
+ export function writeMergedGuardedFile(destPath, gruntBody) {
115
+ if (!fs.existsSync(destPath)) {
116
+ fs.writeFileSync(destPath, mergeGuardedContent("", gruntBody));
117
+ return;
118
+ }
119
+ const buf = fs.readFileSync(destPath);
120
+ if (inspectGuardedBuffer(buf)) return "aborted-unsafe";
121
+ fs.writeFileSync(destPath, mergeGuardedContent(buf.toString("utf8"), gruntBody));
122
+ }
123
+
124
+ export function guardedMarkdownDrift(destPath, gruntBody) {
125
+ if (!fs.existsSync(destPath)) return true;
126
+ const buf = fs.readFileSync(destPath);
127
+ if (inspectGuardedBuffer(buf)) return true;
128
+ const text = buf.toString("utf8");
129
+ const interior = extractGruntBody(text);
130
+ if (interior == null) return !normEq(text, gruntBody);
131
+ return !normEq(interior, gruntBody);
132
+ }
133
+
134
+ export function snapshotGuardedRoots(root) {
135
+ const snap = {};
136
+ for (const file of GUARDED_ROOT_FILES) {
137
+ const p = path.join(root, file);
138
+ if (!fs.existsSync(p)) continue;
139
+ const buf = fs.readFileSync(p);
140
+ const unsafe = inspectGuardedBuffer(buf);
141
+ if (unsafe) {
142
+ snap[file] = { unsafe, rawBuf: buf };
143
+ continue;
144
+ }
145
+ const raw = buf.toString("utf8");
146
+ snap[file] = { raw, user: extractUserMarkdown(raw) };
147
+ }
148
+ return snap;
149
+ }
150
+
151
+ export function remergeGuardedRoots(root, snap) {
152
+ for (const file of GUARDED_ROOT_FILES) {
153
+ const p = path.join(root, file);
154
+ const rec = snap[file];
155
+ if (rec?.unsafe) {
156
+ fs.writeFileSync(p, rec.rawBuf);
157
+ continue;
158
+ }
159
+ if (!fs.existsSync(p)) {
160
+ if (rec?.raw != null) fs.writeFileSync(p, rec.raw);
161
+ continue;
162
+ }
163
+ const buf = fs.readFileSync(p);
164
+ if (inspectGuardedBuffer(buf)) {
165
+ if (rec?.raw != null) fs.writeFileSync(p, rec.raw);
166
+ continue;
167
+ }
168
+ const current = buf.toString("utf8");
169
+ if (rec && current === rec.raw) continue;
170
+ const gruntBody = extractGruntBody(current) ?? current;
171
+ const nl = detectNewline((rec && rec.raw) || current);
172
+ let user = rec ? rec.user : extractUserMarkdown(current);
173
+ if (rec && !rec.raw.includes(SENTINEL_BEGIN) && normEq(rec.raw, gruntBody)) user = "";
174
+ if (!rec && extractGruntBody(current) == null && normEq(current, gruntBody)) user = "";
175
+ fs.writeFileSync(p, composeGuardedMarkdown(gruntBody, user, nl));
176
+ }
177
+ }
178
+
179
+ export function healGuardedRootFile(root, file, snap) {
180
+ if (!GUARDED_ROOT_FILES.includes(file)) return;
181
+ const p = path.join(root, file);
182
+ const rec = snap[file];
183
+ if (!fs.existsSync(p)) return;
184
+ if (rec?.unsafe) {
185
+ fs.writeFileSync(p, rec.rawBuf);
186
+ return;
187
+ }
188
+ const buf = fs.readFileSync(p);
189
+ if (inspectGuardedBuffer(buf)) {
190
+ if (rec?.raw != null) fs.writeFileSync(p, rec.raw);
191
+ return;
192
+ }
193
+ const current = buf.toString("utf8");
194
+ const interior = extractGruntBody(current);
195
+ if (interior != null) {
196
+ snap[file] = { raw: current, user: extractUserMarkdown(current) };
197
+ return;
198
+ }
199
+ if (rec && current === rec.raw) return;
200
+ const gruntBody = current;
201
+ const nl = detectNewline((rec && rec.raw) || current);
202
+ let user = rec ? rec.user : extractUserMarkdown(current);
203
+ if (rec && !rec.raw.includes(SENTINEL_BEGIN) && normEq(rec.raw, gruntBody)) user = "";
204
+ if (!rec && normEq(current, gruntBody)) user = "";
205
+ const out = composeGuardedMarkdown(gruntBody, user, nl);
206
+ fs.writeFileSync(p, out);
207
+ snap[file] = { raw: out, user };
208
+ }
209
+
210
+ export function withGuardedCheckInteriors(root, fn) {
211
+ const snap = snapshotGuardedRoots(root);
212
+ try {
213
+ for (const file of GUARDED_ROOT_FILES) {
214
+ const rec = snap[file];
215
+ if (!rec || rec.unsafe) continue;
216
+ const body = extractGruntBody(rec.raw);
217
+ if (body == null) continue;
218
+ const nl = detectNewline(rec.raw);
219
+ fs.writeFileSync(path.join(root, file), `${toNewline(trimTrailingNewlines(body), nl)}${nl}`);
220
+ }
221
+ return fn();
222
+ } finally {
223
+ for (const file of GUARDED_ROOT_FILES) {
224
+ const rec = snap[file];
225
+ if (!rec) continue;
226
+ if (rec.unsafe) fs.writeFileSync(path.join(root, file), rec.rawBuf);
227
+ else fs.writeFileSync(path.join(root, file), rec.raw);
228
+ }
229
+ }
230
+ }
@@ -10,7 +10,7 @@ import {
10
10
  remergeGuardedRoots,
11
11
  snapshotGuardedRoots,
12
12
  withGuardedCheckInteriors,
13
- } from "../cli/init.mjs";
13
+ } from "./guarded-md.mjs";
14
14
  import { runPipeline } from "./pipeline.mjs";
15
15
 
16
16
  export function attachGuardedRootWatchers(cwd, heal) {
@@ -0,0 +1,15 @@
1
+ /** TTY / CI / --yes probe. Copied into consumers; no clack. */
2
+ export function isInteractive({
3
+ argv = process.argv.slice(2),
4
+ env = process.env,
5
+ stdin = process.stdin,
6
+ stdout = process.stdout,
7
+ } = {}) {
8
+ if (!stdin?.isTTY || !stdout?.isTTY) return false;
9
+ const ci = env.CI;
10
+ if (ci != null && ci !== "" && ci !== "0" && ci !== "false") return false;
11
+ for (const a of argv) {
12
+ if (a === "--yes" || a === "-y" || a === "--non-interactive") return false;
13
+ }
14
+ return true;
15
+ }
@@ -0,0 +1,32 @@
1
+ /** TTY prompts. Copied into consumers. */
2
+ import * as clack from "@clack/prompts";
3
+
4
+ export { isInteractive } from "./interactive.mjs";
5
+
6
+ export function bailIfCancel(value) {
7
+ if (clack.isCancel(value)) {
8
+ clack.cancel("Aborted");
9
+ process.exit(0);
10
+ }
11
+ return value;
12
+ }
13
+
14
+ export async function select(opts) {
15
+ return bailIfCancel(await clack.select(opts));
16
+ }
17
+
18
+ export async function confirm(opts) {
19
+ return bailIfCancel(await clack.confirm(opts));
20
+ }
21
+
22
+ export async function text(opts) {
23
+ return bailIfCancel(await clack.text(opts));
24
+ }
25
+
26
+ export async function password(opts) {
27
+ return bailIfCancel(await clack.password(opts));
28
+ }
29
+
30
+ export function spinner() {
31
+ return clack.spinner();
32
+ }
package/scripts/setup.mjs CHANGED
@@ -4,7 +4,7 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
- import { isInteractive as ttyInteractive } from "../cli/prompt.mjs";
7
+ import { isInteractive as ttyInteractive } from "./interactive.mjs";
8
8
  import {
9
9
  CHROMIUM_BINS,
10
10
  installHints,
@@ -343,7 +343,7 @@ function errLine(io, s) {
343
343
  }
344
344
 
345
345
  async function defaultIo() {
346
- const { confirm, password, select, text } = await import("../cli/prompt.mjs");
346
+ const { confirm, password, select, text } = await import("./prompt.mjs");
347
347
  return {
348
348
  confirm,
349
349
  password,