@lovrozagar/grunt 0.6.2 → 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,19 @@ 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
+
10
23
  ## [0.6.2] - 2026-09-17
11
24
 
12
25
  ### 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
106
  - TTY init asks `Apply global prompt optimizations? (recommended)` (default Yes; `--skip-globals` defaults No)
99
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
@@ -4,6 +4,14 @@ import path from "node:path"
4
4
  import { fileURLToPath } from "node:url"
5
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) {
@@ -111,9 +151,23 @@ function bindSpinner() {
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)) {
@@ -131,10 +185,11 @@ async function runInit(cwd, { skipGlobals, interactive }) {
131
185
  skipGlobals: !applyGlobals,
132
186
  applyGlobals,
133
187
  onPhase: bindSpinner(),
188
+ packageManager,
134
189
  })
135
190
  }
136
191
 
137
- async function dispatch(cmd, flags, interactive) {
192
+ async function dispatch(cmd, flags, interactive, pm) {
138
193
  if (cmd === "help" || cmd === "--help" || cmd === "-h") {
139
194
  process.stdout.write(USAGE)
140
195
  return
@@ -147,38 +202,40 @@ async function dispatch(cmd, flags, interactive) {
147
202
  await runInit(process.cwd(), {
148
203
  skipGlobals: flags.skipGlobals,
149
204
  interactive: interactive && (!cmd || cmd === "init"),
205
+ packageManager: pm,
150
206
  })
151
207
  return
152
208
  }
153
209
  if (cmd === "generate") {
154
- npmRun(toGruntScriptName("rulesync:generate"))
210
+ runScript(pm, toGruntScriptName("rulesync:generate"))
155
211
  return
156
212
  }
157
213
  if (cmd === "check") {
158
- npmRun(toGruntScriptName("rulesync:check"))
214
+ runScript(pm, toGruntScriptName("rulesync:check"))
159
215
  return
160
216
  }
161
217
  if (cmd === "sync-globals") {
162
218
  const script = flags.apply ? toGruntScriptName("sync:globals:apply") : toGruntScriptName("sync:globals")
163
- npmRun(script, hostExtra(flags.host))
219
+ runScript(pm, script, hostExtra(flags.host))
164
220
  return
165
221
  }
166
222
  if (cmd === "purge-mcps") {
167
- 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"))
168
224
  return
169
225
  }
170
226
  if (cmd === "doctor") {
171
- npmRun(toGruntScriptName("doctor"))
227
+ runScript(pm, toGruntScriptName("doctor"))
172
228
  return
173
229
  }
174
230
  if (cmd === "setup") {
175
- npmRun(toGruntScriptName("setup"), flags.args)
231
+ runScript(pm, toGruntScriptName("setup"), flags.args)
176
232
  return
177
233
  }
178
234
  if (cmd === "upgrade") {
179
235
  await runInit(process.cwd(), {
180
236
  skipGlobals: flags.skipGlobals,
181
237
  interactive: false,
238
+ packageManager: pm,
182
239
  })
183
240
  process.stdout.write(`reserved: ${RESERVED_SKILLS.join(" ")}\n`)
184
241
  return
@@ -187,9 +244,13 @@ async function dispatch(cmd, flags, interactive) {
187
244
  process.exitCode = 1
188
245
  }
189
246
 
247
+ function commandNeedsPackageManager(cmd) {
248
+ return !cmd || NEEDS_PM.has(cmd)
249
+ }
250
+
190
251
  export async function start() {
191
252
  const flags = parseArgv(process.argv.slice(2))
192
- if (flags.hostError) {
253
+ if (flags.hostError || flags.pmError) {
193
254
  process.stdout.write(USAGE)
194
255
  process.exitCode = 1
195
256
  return
@@ -205,5 +266,15 @@ export async function start() {
205
266
  if (choice === "quit") return
206
267
  cmd = choice
207
268
  }
208
- 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)
209
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
  });
@@ -408,6 +444,7 @@ describe("start", () => {
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
  );
@@ -418,6 +455,84 @@ describe("start", () => {
418
455
  expect(spin.stop).toHaveBeenCalledWith("install");
419
456
  });
420
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();
534
+ });
535
+
421
536
  it("TTY init --skip-globals default confirm false maps to skip", async () => {
422
537
  isInteractive.mockReturnValue(true);
423
538
  confirm.mockResolvedValue(false);
@@ -432,6 +547,7 @@ describe("start", () => {
432
547
  expect.objectContaining({
433
548
  skipGlobals: true,
434
549
  applyGlobals: false,
550
+ packageManager: "npm",
435
551
  onPhase: expect.any(Function),
436
552
  }),
437
553
  );
@@ -462,3 +578,24 @@ describe("parseArgv --host", () => {
462
578
  });
463
579
  });
464
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
+ });