@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 +24 -0
- package/README.md +34 -24
- package/cli/grunt.mjs +96 -26
- package/cli/grunt.test.ts +152 -14
- package/cli/init.mjs +93 -17
- package/cli/init.test.ts +251 -2
- package/package.json +2 -1
- package/scripts/check-globals.mjs +18 -7
- package/scripts/doctor.mjs +62 -12
- package/scripts/emit-mcp-policy.mjs +13 -7
- package/scripts/package-manager.mjs +150 -0
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
|
-
|
|
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 `
|
|
34
|
-
- Do not
|
|
35
|
-
- Package: `@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
|
-
|
|
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
|
|
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: `
|
|
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,
|
|
77
|
-
- `generate` →
|
|
78
|
-
- `check` →
|
|
79
|
-
- `sync-globals` →
|
|
80
|
-
- `purge-mcps` →
|
|
81
|
-
- `doctor` →
|
|
82
|
-
- `setup` →
|
|
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
|
|
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)`
|
|
99
|
-
-
|
|
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
|
|
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
|
-
|
|
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: `
|
|
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: `
|
|
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,
|
|
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,
|
|
16
|
-
generate
|
|
17
|
-
check
|
|
18
|
-
sync-globals
|
|
19
|
-
purge-mcps
|
|
20
|
-
doctor
|
|
21
|
-
setup
|
|
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
|
|
55
|
-
|
|
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
|
|
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: !
|
|
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
|
-
|
|
210
|
+
runScript(pm, toGruntScriptName("rulesync:generate"))
|
|
156
211
|
return
|
|
157
212
|
}
|
|
158
213
|
if (cmd === "check") {
|
|
159
|
-
|
|
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
|
-
|
|
219
|
+
runScript(pm, script, hostExtra(flags.host))
|
|
165
220
|
return
|
|
166
221
|
}
|
|
167
222
|
if (cmd === "purge-mcps") {
|
|
168
|
-
|
|
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
|
-
|
|
227
|
+
runScript(pm, toGruntScriptName("doctor"))
|
|
173
228
|
return
|
|
174
229
|
}
|
|
175
230
|
if (cmd === "setup") {
|
|
176
|
-
|
|
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
|
-
|
|
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,
|
|
46
|
-
generate
|
|
47
|
-
check
|
|
48
|
-
sync-globals
|
|
49
|
-
purge-mcps
|
|
50
|
-
doctor
|
|
51
|
-
setup
|
|
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(), {
|
|
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(), {
|
|
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(), {
|
|
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(), {
|
|
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(), {
|
|
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:
|
|
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).
|
|
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
|
+
});
|