@lovrozagar/grunt 0.6.1 → 0.7.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/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-09-30
11
+
12
+ ### Added
13
+
14
+ - Package manager detection for init/generate/check/doctor/setup: lockfile or `package.json` `"packageManager"`, then how grunt was launched, then TTY ask. `--pm npm|yarn|pnpm|bun` overrides. Non-interactive with no signal fails instead of assuming npm.
15
+
16
+ ### Fixed
17
+
18
+ - Init no longer forces `npm install` / `npm run`
19
+ - Init keeps `@lovrozagar/grunt` in `devDependencies` (moves it out of `dependencies` when present; adds it when missing)
20
+ - Init INDEX/skills-map/refs-map are generated from the dest tree (grunt refs plus consumer extras) instead of copying the package maps
21
+ - Init writes root `.mcp.json` when dest is missing it
22
+
23
+ ## [0.6.2] - 2026-09-17
24
+
25
+ ### Changed
26
+
27
+ - TTY init defaults `Apply global prompt optimizations? (recommended)` to Yes (`--skip-globals` still defaults No)
28
+
29
+ ### Fixed
30
+
31
+ - Init in `$HOME` no longer fails `emit-mcp-policy --check` after globals write `[features]` into the same `~/.grok/config.toml`
32
+ - TTY init spinner `stop` keeps the phase name (no empty `◇`)
33
+
10
34
  ## [0.6.1] - 2026-09-17
11
35
 
12
36
  ### Changed
package/README.md CHANGED
@@ -23,23 +23,26 @@ OSS drop-in; merges existing configs; switches default provider flow to Grunt.
23
23
  ## Install
24
24
 
25
25
  - Node.js 22+
26
- - Consumer:
26
+ - Consumer (devDependency; init moves it there if it landed in `dependencies`):
27
27
 
28
28
  ```
29
- npm i -D @lovrozagar/grunt
30
- npm exec grunt
29
+ npm i -D @lovrozagar/grunt && npm exec grunt
30
+ pnpm add -D @lovrozagar/grunt && pnpm exec grunt
31
+ yarn add -D @lovrozagar/grunt && yarn grunt
32
+ bun add -D @lovrozagar/grunt && bunx grunt
31
33
  ```
32
34
 
33
- - Same as `npm exec grunt init` when no command
34
- - Do not `npm test` as a consumer
35
- - Package: `@lovrozagar/grunt` `0.6.0` MIT · https://github.com/lovrozagar/grunt
35
+ - Same as `grunt init` when no command
36
+ - Do not run this package's test script as a consumer
37
+ - Package: `@lovrozagar/grunt` MIT · https://github.com/lovrozagar/grunt
38
+ - Package manager: lockfile or `package.json` `"packageManager"`, then how grunt was launched, then TTY ask. `--pm npm|yarn|pnpm|bun` overrides. Non-interactive with no signal fails (does not assume npm).
36
39
 
37
40
  ## Prerequisites
38
41
 
39
42
  All OS. Print-only. Never auto-install.
40
43
 
41
44
  ```
42
- npm exec grunt doctor
45
+ grunt doctor
43
46
  node scripts/doctor.mjs
44
47
  ```
45
48
 
@@ -47,7 +50,8 @@ Exit 1 if any required missing; 0 if all required ok. Optional tools are reporte
47
50
 
48
51
  | tool | required | install |
49
52
  | --- | --- | --- |
50
- | node ≥22 + npm | yes | https://nodejs.org (≥22) · nvm / OS pkg · win `winget install OpenJS.NodeJS.LTS` |
53
+ | node ≥22 | yes | https://nodejs.org (≥22) · nvm / OS pkg · win `winget install OpenJS.NodeJS.LTS` |
54
+ | package manager | yes | npm (ships with Node ≥22) · yarn/pnpm `corepack enable` · bun https://bun.sh · detected from the repo, else how grunt was launched |
51
55
  | git | yes | linux `sudo apt install git` · mac `brew install git` · win `winget install Git.Git` |
52
56
  | rtk | yes | linux/mac `curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/master/install.sh \| sh` or `brew install rtk` · win release zip `rtk.exe` on PATH or WSL curl ([docs](https://www.rtk-ai.app/docs/getting-started/installation/)) |
53
57
  | rulesync | yes | `npm i -D rulesync` / npx (PATH or npx-able) |
@@ -61,7 +65,7 @@ Exit 1 if any required missing; 0 if all required ok. Optional tools are reporte
61
65
  | whisper-cli | no | local STT for `/listen` · mac `brew install whisper-cpp` · first listen downloads `ggml-base.en.bin` · setup in `.rulesync/reference/listen.md` |
62
66
  | listen | no | `node scripts/listen.mjs` · STT input · `grunt setup` / `node scripts/setup.mjs listen` · local whisper.cpp then OpenAI Whisper |
63
67
 
64
- Rulesync schema doctor is separate: `npm run grunt:rulesync:doctor`.
68
+ Rulesync schema doctor is separate: `grunt:rulesync:doctor` (or SoT `rulesync:doctor`).
65
69
 
66
70
  ## Usage
67
71
 
@@ -73,13 +77,13 @@ Rulesync schema doctor is separate: `npm run grunt:rulesync:doctor`.
73
77
 
74
78
  ### Commands
75
79
 
76
- - `init` → `init()` — merge SoT, `npm install`, `grunt:rulesync:generate`, `grunt:sync:globals:apply`, `grunt:rulesync:check`
77
- - `generate` → `npm run grunt:rulesync:generate`
78
- - `check` → `npm run grunt:rulesync:check`
79
- - `sync-globals` → `npm run grunt:sync:globals` (dry-run); `--apply` → `grunt:sync:globals:apply`
80
- - `purge-mcps` → `npm run grunt:purge:global-mcps` (dry-run); `--apply` → `grunt:purge:global-mcps:apply`
81
- - `doctor` → `npm run grunt:doctor` (`npm exec grunt doctor` / `node scripts/doctor.mjs` stay). Rulesync schema: `npm run grunt:rulesync:doctor`
82
- - `setup` → `npm run grunt:setup` (`node scripts/setup.mjs`) — handheld speak / listen / google-workspace / browser. TTY walks each; flags for non-TTY keys/`--creds`
80
+ - `init` → `init()` — merge SoT, install, `grunt:rulesync:generate`, `grunt:sync:globals:apply`, `grunt:rulesync:check`
81
+ - `generate` → run `grunt:rulesync:generate`
82
+ - `check` → run `grunt:rulesync:check`
83
+ - `sync-globals` → run `grunt:sync:globals` (dry-run); `--apply` → `grunt:sync:globals:apply`
84
+ - `purge-mcps` → run `grunt:purge:global-mcps` (dry-run); `--apply` → `grunt:purge:global-mcps:apply`
85
+ - `doctor` → run `grunt:doctor` (`grunt doctor` / `node scripts/doctor.mjs` stay). Rulesync schema: `grunt:rulesync:doctor`
86
+ - `setup` → run `grunt:setup` (`node scripts/setup.mjs`) — handheld speak / listen / google-workspace / browser. TTY walks each; flags for non-TTY keys/`--creds`
83
87
  - `upgrade` → same merge as init for an already-inited repo: copy owned trees/scripts, prune retired grunt-owned names, print reserved skill names
84
88
  - `help`
85
89
  - `version`
@@ -90,25 +94,31 @@ Rulesync schema doctor is separate: `npm run grunt:rulesync:doctor`.
90
94
  - `--yes` / `-y` / `--non-interactive` — no TTY menu; default command still `init`. Not `--apply`
91
95
  - `--apply` — write for `sync-globals` / `purge-mcps`
92
96
  - `--host <id>` — `sync-globals --host <id>`
97
+ - `--pm <name>` — `npm` | `yarn` | `pnpm` | `bun`
93
98
 
94
99
  ## Init
95
100
 
96
- - Merge SoT `npm install` `grunt:rulesync:generate` `grunt:sync:globals:apply` `grunt:rulesync:check`
101
+ - Merge SoT, install with the detected package manager, `grunt:rulesync:generate` `grunt:sync:globals:apply` `grunt:rulesync:check`
102
+ - `@lovrozagar/grunt` is a consumer devDependency. Init moves it out of `dependencies` when it is there, and adds it when missing.
103
+ - INDEX / skills-map / refs-map are generated from the dest `.rulesync` tree (grunt files plus consumer extras). Package copies of those maps are not stamped over dest.
104
+ - Root `.mcp.json` is created when missing; generate still merges from `.rulesync/mcp-policy.jsonc`.
97
105
  - `--skip-globals` skips apply
98
- - TTY init asks `Apply global prompt optimizations? (recommended)` before writing `~/.grok/config.toml`
99
- - Re-init auto-skips globals when `<!-- grunt:begin -->` in `AGENTS.md`/`CLAUDE.md`
106
+ - TTY init asks `Apply global prompt optimizations? (recommended)` (default Yes; `--skip-globals` defaults No)
107
+ - Non-interactive re-init auto-skips globals when `<!-- grunt:begin -->` in `AGENTS.md`/`CLAUDE.md`
100
108
  - First init (no sentinel) applies globals unless flagged
101
109
  - Owned trees/scripts refresh; extra `.rulesync` files kept; patches to grunt-owned files lost
102
110
  - `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.
103
- - Breaking: consumer npm scripts are `grunt:<SoT-key>` (`grunt:rulesync:generate`, `grunt:doctor`). Re-init migrates `package.json` (owned unprefixed keys + suffixes; `npm run` refs in other dest scripts). CI/husky/`npm run rulesync:*` / `npm run doctor` must switch. No aliases. SoT repo scripts stay unprefixed (`npm run rulesync:generate`).
111
+ - Breaking: consumer scripts are `grunt:<SoT-key>` (`grunt:rulesync:generate`, `grunt:doctor`). Re-init migrates `package.json` (owned unprefixed keys + suffixes; `npm|yarn|pnpm|bun run` refs in other dest scripts). CI/husky/`run rulesync:*` / `run doctor` must switch. No aliases. SoT repo scripts stay unprefixed (`rulesync:generate`).
104
112
 
105
113
  ## Version bump
106
114
 
107
115
  Already-inited consumer (0.5.x → 0.6, and later):
108
116
 
109
117
  ```
110
- npm i -D @lovrozagar/grunt@latest
111
- npm exec grunt upgrade
118
+ npm i -D @lovrozagar/grunt@latest && npm exec grunt upgrade
119
+ pnpm add -D @lovrozagar/grunt@latest && pnpm exec grunt upgrade
120
+ yarn add -D @lovrozagar/grunt@latest && yarn grunt upgrade
121
+ bun add -D @lovrozagar/grunt@latest && bunx grunt upgrade
112
122
  ```
113
123
 
114
124
  `upgrade` is init plus a reserved-names print. Same merge, prune, and globals-skip rules as Init. New skills/scripts appear because they are in the package copy list. Dropped grunt-owned files disappear only if they are on the retired lists (or a reserved skill this package no longer ships). Do not expect a blind dest-dir mirror-delete; that would wipe consumer extras.
@@ -155,7 +165,7 @@ Pipeline (no `-t geminicli`):
155
165
  3. `emit-gemini.mjs` — `GEMINI.md` `.gemini/agents/{id}/agent.md` MCP `.gemini/settings.json`
156
166
  4. `emit-agent-shell-tools` — Claude grunt body `Bash`; other hosts `run_terminal_command` (hooks-union)
157
167
 
158
- `check` = rulesync check. `doctor` = unified prereqs. Schema: `npm run rulesync:doctor`.
168
+ `check` = rulesync check. `doctor` = unified prereqs. Schema: `rulesync:doctor`.
159
169
 
160
170
  Emit writes other-CLI trees from `.rulesync` for the **next** process of that CLI. Not a live hop into another host.
161
171
 
@@ -170,7 +180,7 @@ Emit writes other-CLI trees from `.rulesync` for the **next** process of that CL
170
180
 
171
181
  Not git. Not `sync-globals`.
172
182
 
173
- First-hand: `npm exec grunt setup` (TTY). Per target: `node scripts/setup.mjs speak|listen|google-workspace|browser`. Spec: `.rulesync/reference/setup.md`.
183
+ First-hand: `grunt setup` (TTY). Per target: `node scripts/setup.mjs speak|listen|google-workspace|browser`. Spec: `.rulesync/reference/setup.md`.
174
184
 
175
185
  | | where |
176
186
  | --- | --- |
package/cli/grunt.mjs CHANGED
@@ -2,8 +2,16 @@ 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
+ import {
8
+ PACKAGE_MANAGER_ASK,
9
+ PACKAGE_MANAGER_OPTIONS,
10
+ UNKNOWN_PACKAGE_MANAGER,
11
+ detectPackageManager,
12
+ isPackageManager,
13
+ runScriptArgs,
14
+ } from "../scripts/package-manager.mjs"
7
15
 
8
16
  const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
9
17
 
@@ -12,13 +20,13 @@ const USAGE = `Usage: grunt [command]
12
20
  Default (no command): TTY menu; else init — full setup
13
21
 
14
22
  Commands:
15
- init Full setup: merge SoT, npm install, grunt:rulesync:generate, grunt:sync:globals:apply, grunt:rulesync:check
16
- generate npm run grunt:rulesync:generate
17
- check npm run grunt:rulesync:check
18
- sync-globals npm run grunt:sync:globals (dry-run; --apply to write)
19
- purge-mcps npm run grunt:purge:global-mcps (dry-run; --apply to write)
20
- doctor npm run grunt:doctor
21
- setup npm run grunt:setup — handheld keys/OAuth (speak, listen, google-workspace, browser)
23
+ init Full setup: merge SoT, install, grunt:rulesync:generate, grunt:sync:globals:apply, grunt:rulesync:check
24
+ generate run grunt:rulesync:generate
25
+ check run grunt:rulesync:check
26
+ sync-globals run grunt:sync:globals (dry-run; --apply to write)
27
+ purge-mcps run grunt:purge:global-mcps (dry-run; --apply to write)
28
+ doctor run grunt:doctor
29
+ setup run grunt:setup — handheld keys/OAuth (speak, listen, google-workspace, browser)
22
30
  upgrade Re-init: copy owned files, prune retired grunt-owned names, print reserved skills
23
31
  help Show this help
24
32
  version Print package version
@@ -29,6 +37,7 @@ Flags:
29
37
  --non-interactive Same as --yes
30
38
  --apply Write for sync-globals / purge-mcps
31
39
  --host <id> sync-globals host
40
+ --pm <name> npm | yarn | pnpm | bun (else lockfile, then how grunt was launched, then ask)
32
41
  `
33
42
 
34
43
  const MENU_OPTIONS = [
@@ -45,26 +54,41 @@ const MENU_OPTIONS = [
45
54
  ]
46
55
 
47
56
  const YES_FLAGS = new Set(["--yes", "-y", "--non-interactive"])
57
+ const NEEDS_PM = new Set([
58
+ "init",
59
+ "generate",
60
+ "check",
61
+ "sync-globals",
62
+ "purge-mcps",
63
+ "doctor",
64
+ "setup",
65
+ "upgrade",
66
+ ])
48
67
 
49
68
  function pkgVersion() {
50
69
  const pkg = JSON.parse(readFileSync(path.join(PKG_ROOT, "package.json"), "utf8"))
51
70
  return pkg.version
52
71
  }
53
72
 
54
- function npmRun(script, extra = []) {
55
- const args = extra.length ? ["run", script, "--", ...extra] : ["run", script]
56
- execFileSync("npm", args, { cwd: process.cwd(), stdio: "inherit" })
73
+ function runScript(pm, script, extra = []) {
74
+ execFileSync(pm, runScriptArgs(pm, script, extra), { cwd: process.cwd(), stdio: "inherit" })
57
75
  }
58
76
 
59
77
  function hostValueOk(v) {
60
78
  return v != null && v !== "" && !String(v).startsWith("-")
61
79
  }
62
80
 
81
+ function pmValueOk(v) {
82
+ return v != null && v !== "" && isPackageManager(v)
83
+ }
84
+
63
85
  export function parseArgv(argv) {
64
86
  let skipGlobals = false
65
87
  let apply = false
66
88
  let host
67
89
  let hostError = false
90
+ let pm
91
+ let pmError = false
68
92
  const positionals = []
69
93
  for (let i = 0; i < argv.length; i++) {
70
94
  const a = argv[i]
@@ -92,9 +116,25 @@ export function parseArgv(argv) {
92
116
  if (!hostValueOk(host)) hostError = true
93
117
  continue
94
118
  }
119
+ if (a === "--pm") {
120
+ const v = argv[i + 1]
121
+ if (!pmValueOk(v)) {
122
+ pmError = true
123
+ continue
124
+ }
125
+ pm = String(v).toLowerCase()
126
+ i += 1
127
+ continue
128
+ }
129
+ if (typeof a === "string" && a.startsWith("--pm=")) {
130
+ pm = a.slice("--pm=".length)
131
+ if (!pmValueOk(pm)) pmError = true
132
+ else pm = pm.toLowerCase()
133
+ continue
134
+ }
95
135
  positionals.push(a)
96
136
  }
97
- return { cmd: positionals[0], args: positionals.slice(1), skipGlobals, apply, host, hostError }
137
+ return { cmd: positionals[0], args: positionals.slice(1), skipGlobals, apply, host, hostError, pm, pmError }
98
138
  }
99
139
 
100
140
  function hostExtra(host) {
@@ -105,15 +145,29 @@ function bindSpinner() {
105
145
  const spin = spinner()
106
146
  return (name, action) => {
107
147
  if (action === "start") spin.start(name)
108
- else if (action === "stop") spin.stop()
148
+ else if (action === "stop") spin.stop(name)
109
149
  }
110
150
  }
111
151
 
112
152
  export const APPLY_GLOBALS_CONFIRM = "Apply global prompt optimizations? (recommended)"
113
153
 
114
- async function runInit(cwd, { skipGlobals, interactive }) {
154
+ export async function resolveCliPackageManager({ cwd, env, override, interactive }) {
155
+ const detected = detectPackageManager({ cwd, env, override })
156
+ if (detected.manager) return detected.manager
157
+ if (interactive) {
158
+ return await select({
159
+ message: PACKAGE_MANAGER_ASK,
160
+ options: PACKAGE_MANAGER_OPTIONS,
161
+ })
162
+ }
163
+ process.stdout.write(`${UNKNOWN_PACKAGE_MANAGER}\n`)
164
+ process.exitCode = 1
165
+ return null
166
+ }
167
+
168
+ async function runInit(cwd, { skipGlobals, interactive, packageManager }) {
115
169
  if (!interactive) {
116
- init(cwd, { skipGlobals })
170
+ init(cwd, { skipGlobals, packageManager })
117
171
  return
118
172
  }
119
173
  if (destAlreadyInited(cwd)) {
@@ -123,19 +177,19 @@ async function runInit(cwd, { skipGlobals, interactive }) {
123
177
  })
124
178
  if (!again) return
125
179
  }
126
- const autoSkip = skipGlobals || shouldAutoSkipGlobals(cwd)
127
180
  const applyGlobals = await confirm({
128
181
  message: APPLY_GLOBALS_CONFIRM,
129
- initialValue: !autoSkip,
182
+ initialValue: !skipGlobals,
130
183
  })
131
184
  init(cwd, {
132
185
  skipGlobals: !applyGlobals,
133
186
  applyGlobals,
134
187
  onPhase: bindSpinner(),
188
+ packageManager,
135
189
  })
136
190
  }
137
191
 
138
- async function dispatch(cmd, flags, interactive) {
192
+ async function dispatch(cmd, flags, interactive, pm) {
139
193
  if (cmd === "help" || cmd === "--help" || cmd === "-h") {
140
194
  process.stdout.write(USAGE)
141
195
  return
@@ -148,38 +202,40 @@ async function dispatch(cmd, flags, interactive) {
148
202
  await runInit(process.cwd(), {
149
203
  skipGlobals: flags.skipGlobals,
150
204
  interactive: interactive && (!cmd || cmd === "init"),
205
+ packageManager: pm,
151
206
  })
152
207
  return
153
208
  }
154
209
  if (cmd === "generate") {
155
- npmRun(toGruntScriptName("rulesync:generate"))
210
+ runScript(pm, toGruntScriptName("rulesync:generate"))
156
211
  return
157
212
  }
158
213
  if (cmd === "check") {
159
- npmRun(toGruntScriptName("rulesync:check"))
214
+ runScript(pm, toGruntScriptName("rulesync:check"))
160
215
  return
161
216
  }
162
217
  if (cmd === "sync-globals") {
163
218
  const script = flags.apply ? toGruntScriptName("sync:globals:apply") : toGruntScriptName("sync:globals")
164
- npmRun(script, hostExtra(flags.host))
219
+ runScript(pm, script, hostExtra(flags.host))
165
220
  return
166
221
  }
167
222
  if (cmd === "purge-mcps") {
168
- npmRun(flags.apply ? toGruntScriptName("purge:global-mcps:apply") : toGruntScriptName("purge:global-mcps"))
223
+ runScript(pm, flags.apply ? toGruntScriptName("purge:global-mcps:apply") : toGruntScriptName("purge:global-mcps"))
169
224
  return
170
225
  }
171
226
  if (cmd === "doctor") {
172
- npmRun(toGruntScriptName("doctor"))
227
+ runScript(pm, toGruntScriptName("doctor"))
173
228
  return
174
229
  }
175
230
  if (cmd === "setup") {
176
- npmRun(toGruntScriptName("setup"), flags.args)
231
+ runScript(pm, toGruntScriptName("setup"), flags.args)
177
232
  return
178
233
  }
179
234
  if (cmd === "upgrade") {
180
235
  await runInit(process.cwd(), {
181
236
  skipGlobals: flags.skipGlobals,
182
237
  interactive: false,
238
+ packageManager: pm,
183
239
  })
184
240
  process.stdout.write(`reserved: ${RESERVED_SKILLS.join(" ")}\n`)
185
241
  return
@@ -188,9 +244,13 @@ async function dispatch(cmd, flags, interactive) {
188
244
  process.exitCode = 1
189
245
  }
190
246
 
247
+ function commandNeedsPackageManager(cmd) {
248
+ return !cmd || NEEDS_PM.has(cmd)
249
+ }
250
+
191
251
  export async function start() {
192
252
  const flags = parseArgv(process.argv.slice(2))
193
- if (flags.hostError) {
253
+ if (flags.hostError || flags.pmError) {
194
254
  process.stdout.write(USAGE)
195
255
  process.exitCode = 1
196
256
  return
@@ -206,5 +266,15 @@ export async function start() {
206
266
  if (choice === "quit") return
207
267
  cmd = choice
208
268
  }
209
- await dispatch(cmd, flags, interactive)
269
+ let pm
270
+ if (commandNeedsPackageManager(cmd)) {
271
+ pm = await resolveCliPackageManager({
272
+ cwd: process.cwd(),
273
+ env: process.env,
274
+ override: flags.pm,
275
+ interactive,
276
+ })
277
+ if (!pm) return
278
+ }
279
+ await dispatch(cmd, flags, interactive, pm)
210
280
  }
package/cli/grunt.test.ts CHANGED
@@ -10,6 +10,8 @@ const toGruntScriptName = vi.hoisted(
10
10
  () => (k) => (String(k).startsWith("grunt:") ? k : `grunt:${k}`),
11
11
  );
12
12
  const execFileSync = vi.hoisted(() => vi.fn());
13
+ const detectPackageManager = vi.hoisted(() => vi.fn());
14
+ const actualDetect = vi.hoisted(() => ({ fn: null as null | ((...args: unknown[]) => unknown) }));
13
15
  const isInteractive = vi.hoisted(() => vi.fn(() => false));
14
16
  const select = vi.hoisted(() => vi.fn());
15
17
  const confirm = vi.hoisted(() => vi.fn());
@@ -27,6 +29,16 @@ vi.mock("./init.mjs", () => ({
27
29
  GRUNT_NPM_PREFIX: "grunt:",
28
30
  }));
29
31
  vi.mock("node:child_process", () => ({ execFileSync }));
32
+ vi.mock("../scripts/package-manager.mjs", async (importOriginal) => {
33
+ const actual = await importOriginal<typeof import("../scripts/package-manager.mjs")>();
34
+ actualDetect.fn = actual.detectPackageManager;
35
+ detectPackageManager.mockImplementation((...args: unknown[]) =>
36
+ actual.detectPackageManager(
37
+ ...(args as Parameters<typeof actual.detectPackageManager>),
38
+ ),
39
+ );
40
+ return { ...actual, detectPackageManager };
41
+ });
30
42
  vi.mock("./prompt.mjs", () => ({
31
43
  isInteractive,
32
44
  select,
@@ -35,6 +47,10 @@ vi.mock("./prompt.mjs", () => ({
35
47
  bailIfCancel,
36
48
  }));
37
49
 
50
+ import {
51
+ PACKAGE_MANAGER_ASK,
52
+ UNKNOWN_PACKAGE_MANAGER,
53
+ } from "../scripts/package-manager.mjs";
38
54
  import { APPLY_GLOBALS_CONFIRM, parseArgv, start } from "./grunt.mjs";
39
55
 
40
56
  const USAGE = `Usage: grunt [command]
@@ -42,13 +58,13 @@ const USAGE = `Usage: grunt [command]
42
58
  Default (no command): TTY menu; else init — full setup
43
59
 
44
60
  Commands:
45
- init Full setup: merge SoT, npm install, grunt:rulesync:generate, grunt:sync:globals:apply, grunt:rulesync:check
46
- generate npm run grunt:rulesync:generate
47
- check npm run grunt:rulesync:check
48
- sync-globals npm run grunt:sync:globals (dry-run; --apply to write)
49
- purge-mcps npm run grunt:purge:global-mcps (dry-run; --apply to write)
50
- doctor npm run grunt:doctor
51
- setup npm run grunt:setup — handheld keys/OAuth (speak, listen, google-workspace, browser)
61
+ init Full setup: merge SoT, install, grunt:rulesync:generate, grunt:sync:globals:apply, grunt:rulesync:check
62
+ generate run grunt:rulesync:generate
63
+ check run grunt:rulesync:check
64
+ sync-globals run grunt:sync:globals (dry-run; --apply to write)
65
+ purge-mcps run grunt:purge:global-mcps (dry-run; --apply to write)
66
+ doctor run grunt:doctor
67
+ setup run grunt:setup — handheld keys/OAuth (speak, listen, google-workspace, browser)
52
68
  upgrade Re-init: copy owned files, prune retired grunt-owned names, print reserved skills
53
69
  help Show this help
54
70
  version Print package version
@@ -59,6 +75,7 @@ Flags:
59
75
  --non-interactive Same as --yes
60
76
  --apply Write for sync-globals / purge-mcps
61
77
  --host <id> sync-globals host
78
+ --pm <name> npm | yarn | pnpm | bun (else lockfile, then how grunt was launched, then ask)
62
79
  `;
63
80
 
64
81
  const pkgRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -88,6 +105,10 @@ describe("start", () => {
88
105
  shouldAutoSkipGlobals.mockReset();
89
106
  shouldAutoSkipGlobals.mockReturnValue(false);
90
107
  execFileSync.mockReset();
108
+ detectPackageManager.mockReset();
109
+ detectPackageManager.mockImplementation((...args: unknown[]) =>
110
+ (actualDetect.fn as (...a: unknown[]) => unknown)(...args),
111
+ );
91
112
  isInteractive.mockReset();
92
113
  isInteractive.mockReturnValue(false);
93
114
  select.mockReset();
@@ -125,7 +146,10 @@ describe("start", () => {
125
146
  process.argv = ["node", "grunt"];
126
147
  await start();
127
148
  expect(init).toHaveBeenCalledOnce();
128
- expect(init).toHaveBeenCalledWith(process.cwd(), { skipGlobals: false });
149
+ expect(init).toHaveBeenCalledWith(process.cwd(), {
150
+ skipGlobals: false,
151
+ packageManager: "npm",
152
+ });
129
153
  expect(execFileSync).not.toHaveBeenCalled();
130
154
  expect(select).not.toHaveBeenCalled();
131
155
  });
@@ -134,7 +158,10 @@ describe("start", () => {
134
158
  process.argv = ["node", "grunt", "init"];
135
159
  await start();
136
160
  expect(init).toHaveBeenCalledOnce();
137
- expect(init).toHaveBeenCalledWith(process.cwd(), { skipGlobals: false });
161
+ expect(init).toHaveBeenCalledWith(process.cwd(), {
162
+ skipGlobals: false,
163
+ packageManager: "npm",
164
+ });
138
165
  });
139
166
 
140
167
  it.each([
@@ -145,7 +172,10 @@ describe("start", () => {
145
172
  process.argv = argv;
146
173
  await start();
147
174
  expect(init).toHaveBeenCalledOnce();
148
- expect(init).toHaveBeenCalledWith(process.cwd(), { skipGlobals: true });
175
+ expect(init).toHaveBeenCalledWith(process.cwd(), {
176
+ skipGlobals: true,
177
+ packageManager: "npm",
178
+ });
149
179
  });
150
180
 
151
181
  it.each(["--yes", "-y", "--non-interactive"])(
@@ -156,7 +186,10 @@ describe("start", () => {
156
186
  expect(isInteractive).toHaveBeenCalledOnce();
157
187
  expect(select).not.toHaveBeenCalled();
158
188
  expect(init).toHaveBeenCalledOnce();
159
- expect(init).toHaveBeenCalledWith(process.cwd(), { skipGlobals: false });
189
+ expect(init).toHaveBeenCalledWith(process.cwd(), {
190
+ skipGlobals: false,
191
+ packageManager: "npm",
192
+ });
160
193
  },
161
194
  );
162
195
 
@@ -280,7 +313,10 @@ describe("start", () => {
280
313
  process.argv = ["node", "grunt", "upgrade"];
281
314
  await start();
282
315
  expect(init).toHaveBeenCalledOnce();
283
- expect(init).toHaveBeenCalledWith(process.cwd(), { skipGlobals: false });
316
+ expect(init).toHaveBeenCalledWith(process.cwd(), {
317
+ skipGlobals: false,
318
+ packageManager: "npm",
319
+ });
284
320
  expect(chunks.join("")).toBe("reserved: browser tmp write-plan\n");
285
321
  expect(select).not.toHaveBeenCalled();
286
322
  });
@@ -401,20 +437,100 @@ describe("start", () => {
401
437
  );
402
438
  expect(globalsConfirm?.[0]).toMatchObject({
403
439
  message: APPLY_GLOBALS_CONFIRM,
404
- initialValue: false,
440
+ initialValue: true,
405
441
  });
406
442
  expect(init).toHaveBeenCalledWith(
407
443
  process.cwd(),
408
444
  expect.objectContaining({
409
445
  skipGlobals: false,
410
446
  applyGlobals: true,
447
+ packageManager: "npm",
411
448
  onPhase: expect.any(Function),
412
449
  }),
413
450
  );
414
451
  expect(isInteractive).toHaveBeenCalledOnce();
415
452
  expect(spin.start).toHaveBeenCalledWith("merge");
416
- expect(spin.stop).toHaveBeenCalled();
453
+ expect(spin.stop).toHaveBeenCalledWith("merge");
417
454
  expect(spin.start).toHaveBeenCalledWith("install");
455
+ expect(spin.stop).toHaveBeenCalledWith("install");
456
+ });
457
+
458
+ it("init --pm pnpm passes packageManager", async () => {
459
+ process.argv = ["node", "grunt", "init", "--pm", "pnpm"];
460
+ await start();
461
+ expect(init).toHaveBeenCalledWith(process.cwd(), {
462
+ skipGlobals: false,
463
+ packageManager: "pnpm",
464
+ });
465
+ });
466
+
467
+ it("generate --pm pnpm runs pnpm", async () => {
468
+ process.argv = ["node", "grunt", "generate", "--pm", "pnpm"];
469
+ await start();
470
+ expect(execFileSync).toHaveBeenCalledWith("pnpm", ["run", "grunt:rulesync:generate"], {
471
+ cwd: process.cwd(),
472
+ stdio: "inherit",
473
+ });
474
+ });
475
+
476
+ it("setup --pm=yarn extra args omit npm --", async () => {
477
+ process.argv = ["node", "grunt", "setup", "--pm=yarn", "speak"];
478
+ await start();
479
+ expect(execFileSync).toHaveBeenCalledWith("yarn", ["run", "grunt:setup", "speak"], {
480
+ cwd: process.cwd(),
481
+ stdio: "inherit",
482
+ });
483
+ });
484
+
485
+ it("invalid --pm writes usage exit 1", async () => {
486
+ process.argv = ["node", "grunt", "generate", "--pm", "deno"];
487
+ await start();
488
+ expect(chunks.join("")).toBe(USAGE);
489
+ expect(process.exitCode).toBe(1);
490
+ expect(execFileSync).not.toHaveBeenCalled();
491
+ });
492
+
493
+ it("TTY menu then unknown manager asks", async () => {
494
+ detectPackageManager.mockReturnValue({ manager: null, source: null });
495
+ isInteractive.mockReturnValue(true);
496
+ select.mockResolvedValueOnce("generate").mockResolvedValueOnce("pnpm");
497
+ process.argv = ["node", "grunt"];
498
+ await start();
499
+ expect(select).toHaveBeenCalledTimes(2);
500
+ expect(execFileSync).toHaveBeenCalledWith("pnpm", ["run", "grunt:rulesync:generate"], {
501
+ cwd: process.cwd(),
502
+ stdio: "inherit",
503
+ });
504
+ });
505
+
506
+ it("TTY unknown manager asks then runs the choice", async () => {
507
+ detectPackageManager.mockReturnValue({ manager: null, source: null });
508
+ isInteractive.mockReturnValue(true);
509
+ select.mockResolvedValue("bun");
510
+ process.argv = ["node", "grunt", "generate"];
511
+ await start();
512
+ expect(select).toHaveBeenCalledWith({
513
+ message: PACKAGE_MANAGER_ASK,
514
+ options: [
515
+ { value: "npm", label: "npm" },
516
+ { value: "yarn", label: "yarn" },
517
+ { value: "pnpm", label: "pnpm" },
518
+ { value: "bun", label: "bun" },
519
+ ],
520
+ });
521
+ expect(execFileSync).toHaveBeenCalledWith("bun", ["run", "grunt:rulesync:generate"], {
522
+ cwd: process.cwd(),
523
+ stdio: "inherit",
524
+ });
525
+ });
526
+
527
+ it("non-interactive unknown manager writes hint and skips run", async () => {
528
+ detectPackageManager.mockReturnValue({ manager: null, source: null });
529
+ process.argv = ["node", "grunt", "generate"];
530
+ await start();
531
+ expect(chunks.join("")).toBe(`${UNKNOWN_PACKAGE_MANAGER}\n`);
532
+ expect(process.exitCode).toBe(1);
533
+ expect(execFileSync).not.toHaveBeenCalled();
418
534
  });
419
535
 
420
536
  it("TTY init --skip-globals default confirm false maps to skip", async () => {
@@ -431,6 +547,7 @@ describe("start", () => {
431
547
  expect.objectContaining({
432
548
  skipGlobals: true,
433
549
  applyGlobals: false,
550
+ packageManager: "npm",
434
551
  onPhase: expect.any(Function),
435
552
  }),
436
553
  );
@@ -461,3 +578,24 @@ describe("parseArgv --host", () => {
461
578
  });
462
579
  });
463
580
  });
581
+
582
+ describe("parseArgv --pm", () => {
583
+ it("pair and equals", () => {
584
+ expect(parseArgv(["generate", "--pm", "PNPM"])).toMatchObject({
585
+ cmd: "generate",
586
+ pm: "pnpm",
587
+ pmError: false,
588
+ });
589
+ expect(parseArgv(["generate", "--pm=Yarn"])).toMatchObject({
590
+ pm: "yarn",
591
+ pmError: false,
592
+ });
593
+ });
594
+
595
+ it("missing, empty, flag, and unknown are pmError", () => {
596
+ expect(parseArgv(["generate", "--pm"])).toMatchObject({ pmError: true });
597
+ expect(parseArgv(["generate", "--pm="])).toMatchObject({ pmError: true });
598
+ expect(parseArgv(["generate", "--pm", "--yes"])).toMatchObject({ pmError: true });
599
+ expect(parseArgv(["generate", "--pm", "deno"])).toMatchObject({ pmError: true });
600
+ });
601
+ });