@lovrozagar/grunt 0.9.4 → 0.10.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/.agents/skills/jev/SKILL.md +18 -0
- package/.claude/skills/jev/SKILL.md +18 -0
- package/.grok/hooks/orchestrate-parent.js +9 -1
- package/.grok/skills/jev/SKILL.md +18 -0
- package/.rulesync/reference/INDEX.md +4 -2
- package/.rulesync/reference/hooks.md +1 -1
- package/.rulesync/reference/jev.md +56 -0
- package/.rulesync/reference/law.md +2 -2
- package/.rulesync/reference/refs-map.md +1 -0
- package/.rulesync/reference/setup.md +3 -1
- package/.rulesync/reference/skills-map.md +1 -0
- package/.rulesync/skills/jev/SKILL.md +15 -0
- package/CHANGELOG.md +15 -0
- package/README.md +19 -7
- package/cli/grunt.mjs +60 -8
- package/cli/grunt.test.ts +122 -1
- package/cli/init.mjs +118 -10
- package/cli/init.test.ts +302 -5
- package/cli/prompt.mjs +1 -0
- package/cli/prompt.test.ts +29 -0
- package/package.json +4 -1
- package/scripts/board.mjs +390 -0
- package/scripts/doctor.mjs +53 -15
- package/scripts/feature-packs.mjs +123 -0
- package/scripts/guarded-md.mjs +28 -0
- package/scripts/jev.mjs +355 -0
- package/scripts/listen.mjs +2 -1
- package/scripts/pipeline.mjs +84 -2
- package/scripts/prompt.mjs +4 -0
- package/scripts/session-map.mjs +4 -2
- package/scripts/setup.mjs +85 -47
- package/scripts/speak.mjs +2 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Machine-local live agent board. One JSON file per session under ~/.grunt/board/.
|
|
4
|
+
* A read prints lines whose host process is still alive and deletes the rest.
|
|
5
|
+
* Fail-open: a probe or IO error skips the write and prints nothing.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
|
+
|
|
13
|
+
const WORK_CAP = 80;
|
|
14
|
+
const MAX_WALK = 4;
|
|
15
|
+
const SHELLS = new Set(["sh", "bash", "dash", "zsh", "ash", "busybox", "cmd", "powershell", "pwsh"]);
|
|
16
|
+
|
|
17
|
+
function field(value) {
|
|
18
|
+
const s = String(value ?? "").replace(/\s+/g, "-").trim();
|
|
19
|
+
return s || "-";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function oneLine(value, cap) {
|
|
23
|
+
const s = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
24
|
+
const cut = cap ? s.slice(0, cap).trim() : s;
|
|
25
|
+
return cut || "-";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function commBase(comm) {
|
|
29
|
+
const base = String(comm || "").split(/[/\\]/).pop() || "";
|
|
30
|
+
return base.replace(/\.exe$/i, "").toLowerCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safe(value) {
|
|
34
|
+
return field(value).replace(/[^\w.@+-]/g, "_") || "-";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatLine(row) {
|
|
38
|
+
return [
|
|
39
|
+
field(row.time),
|
|
40
|
+
field(row.provider),
|
|
41
|
+
field(row.model),
|
|
42
|
+
field(row.effort),
|
|
43
|
+
field(row.session),
|
|
44
|
+
oneLine(row.cwd),
|
|
45
|
+
oneLine(row.work, WORK_CAP),
|
|
46
|
+
].join(" ");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function boardHome(env = process.env) {
|
|
50
|
+
return env.GRUNT_BOARD_HOME || os.homedir();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function boardDir(home) {
|
|
54
|
+
return path.join(home, ".grunt", "board");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function rowPath(dir, provider, session) {
|
|
58
|
+
return path.join(dir, `${safe(provider)}-${safe(session)}.json`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readRow(file) {
|
|
62
|
+
try {
|
|
63
|
+
const row = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
64
|
+
return row && typeof row === "object" ? row : null;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ensureDir(dir) {
|
|
71
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
72
|
+
try {
|
|
73
|
+
fs.chmodSync(dir, 0o700);
|
|
74
|
+
} catch {
|
|
75
|
+
// Windows cannot always chmod a directory.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function atomicWrite(file, text) {
|
|
80
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
81
|
+
fs.writeFileSync(tmp, text, { mode: 0o600 });
|
|
82
|
+
fs.renameSync(tmp, file);
|
|
83
|
+
try {
|
|
84
|
+
fs.chmodSync(file, 0o600);
|
|
85
|
+
} catch {
|
|
86
|
+
// Windows cannot always chmod a file.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function readBootId({ readFile = fs.readFileSync, platform = process.platform } = {}) {
|
|
91
|
+
if (platform !== "linux") return "-";
|
|
92
|
+
try {
|
|
93
|
+
return String(readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim() || "-";
|
|
94
|
+
} catch {
|
|
95
|
+
return "-";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Linux /proc/pid/stat. Field 22 is starttime. comm sits between the first "(" and the last ")". */
|
|
100
|
+
export function parseProcStat(text) {
|
|
101
|
+
const s = String(text ?? "");
|
|
102
|
+
const end = s.lastIndexOf(")");
|
|
103
|
+
const open = s.indexOf("(");
|
|
104
|
+
if (open < 0 || end < open) return null;
|
|
105
|
+
const comm = s.slice(open + 1, end);
|
|
106
|
+
const rest = s.slice(end + 1).trim().split(/\s+/);
|
|
107
|
+
if (rest.length < 20) return null;
|
|
108
|
+
const ppid = Number(rest[1]);
|
|
109
|
+
if (!rest[19] || !Number.isFinite(ppid)) return null;
|
|
110
|
+
return { comm, state: rest[0], ppid, start: rest[19] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function probeProcess(pid, deps = {}) {
|
|
114
|
+
const n = Number(pid);
|
|
115
|
+
if (!Number.isInteger(n) || n <= 1) return null;
|
|
116
|
+
const platform = deps.platform || process.platform;
|
|
117
|
+
const readFile = deps.readFile || fs.readFileSync;
|
|
118
|
+
const spawn = deps.spawn || spawnSync;
|
|
119
|
+
if (platform === "linux") {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = parseProcStat(readFile(`/proc/${n}/stat`, "utf8"));
|
|
122
|
+
if (!parsed || parsed.state === "Z") return null;
|
|
123
|
+
return { pid: n, start: parsed.start, comm: parsed.comm, ppid: parsed.ppid };
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (platform === "darwin") {
|
|
129
|
+
const r = spawn("ps", ["-o", "ppid=,lstart=,comm=", "-p", String(n)], { encoding: "utf8" });
|
|
130
|
+
if (!r || r.status !== 0) return null;
|
|
131
|
+
const line = String(r.stdout || "").trim();
|
|
132
|
+
const m = line.match(/^(\d+)\s+([A-Za-z]{3}\s+[A-Za-z]{3}\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(\S+)\s*$/);
|
|
133
|
+
if (!m) return null;
|
|
134
|
+
return { pid: n, start: m[2], comm: commBase(m[3]), ppid: Number(m[1]) };
|
|
135
|
+
}
|
|
136
|
+
if (platform === "win32") {
|
|
137
|
+
const script = [
|
|
138
|
+
`$p = Get-CimInstance Win32_Process -Filter "ProcessId=${n}"`,
|
|
139
|
+
"if (-not $p) { exit 1 }",
|
|
140
|
+
"$p.ParentProcessId",
|
|
141
|
+
"$p.Name",
|
|
142
|
+
"$p.CreationDate.ToUniversalTime().ToString('o')",
|
|
143
|
+
].join("; ");
|
|
144
|
+
const r = spawn("powershell.exe", ["-NoProfile", "-Command", script], { encoding: "utf8" });
|
|
145
|
+
if (!r || r.status !== 0) return null;
|
|
146
|
+
const parts = String(r.stdout || "").trim().split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
147
|
+
if (parts.length < 3) return null;
|
|
148
|
+
const ppid = Number(parts[0]);
|
|
149
|
+
if (!Number.isFinite(ppid) || !parts[2]) return null;
|
|
150
|
+
return { pid: n, start: parts[2], comm: parts[1], ppid };
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function hostIdentity({ pid = process.pid, probe = probeProcess } = {}) {
|
|
156
|
+
let current = probe(pid)?.ppid;
|
|
157
|
+
for (let i = 0; i < MAX_WALK && current && current > 1; i++) {
|
|
158
|
+
const info = probe(current);
|
|
159
|
+
if (!info || !(info.pid > 1) || info.start == null || info.start === "") return null;
|
|
160
|
+
const name = commBase(info.comm);
|
|
161
|
+
if (!SHELLS.has(name)) return { pid: info.pid, start: String(info.start) };
|
|
162
|
+
if (!(info.ppid > 1) || info.ppid === current) return null;
|
|
163
|
+
current = info.ppid;
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function rowAlive(row, { boot, probe = probeProcess } = {}) {
|
|
169
|
+
if (!row || row.pid == null || row.start == null || row.start === "") return false;
|
|
170
|
+
if (String(row.boot ?? "") !== String(boot ?? "")) return false;
|
|
171
|
+
let info;
|
|
172
|
+
try {
|
|
173
|
+
info = probe(row.pid);
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
if (!info || info.start == null || info.start === "") return false;
|
|
178
|
+
return String(info.start) === String(row.start);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function sweep({ home, boot, probe = probeProcess } = {}) {
|
|
182
|
+
const dir = boardDir(home);
|
|
183
|
+
if (!fs.existsSync(dir)) return [];
|
|
184
|
+
const kept = [];
|
|
185
|
+
for (const name of fs.readdirSync(dir)) {
|
|
186
|
+
const file = path.join(dir, name);
|
|
187
|
+
if (!name.endsWith(".json")) continue;
|
|
188
|
+
const row = readRow(file);
|
|
189
|
+
if (!row || !rowAlive(row, { boot, probe })) {
|
|
190
|
+
fs.rmSync(file, { force: true });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
kept.push(row);
|
|
194
|
+
}
|
|
195
|
+
kept.sort((a, b) => String(a.time).localeCompare(String(b.time)));
|
|
196
|
+
return kept;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function nextWork(prev, opts) {
|
|
200
|
+
if (opts.forceWork) return { work: oneLine(opts.work, WORK_CAP), sticky: true };
|
|
201
|
+
if (prev?.sticky && prev.work) return { work: String(prev.work), sticky: true };
|
|
202
|
+
if (opts.work != null && String(opts.work).trim()) return { work: oneLine(opts.work, WORK_CAP), sticky: false };
|
|
203
|
+
if (prev?.work) return { work: String(prev.work), sticky: false };
|
|
204
|
+
return { work: "active", sticky: false };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function note(opts = {}) {
|
|
208
|
+
const session = String(opts.session ?? "").trim();
|
|
209
|
+
if (!session || session === "default") return null;
|
|
210
|
+
if (opts.pid == null || opts.start == null || opts.start === "") return null;
|
|
211
|
+
const home = opts.home ?? boardHome(opts.env);
|
|
212
|
+
const dir = boardDir(home);
|
|
213
|
+
ensureDir(dir);
|
|
214
|
+
const provider = field(opts.provider);
|
|
215
|
+
const file = rowPath(dir, provider, session);
|
|
216
|
+
const prev = readRow(file);
|
|
217
|
+
const chosen = nextWork(prev, opts);
|
|
218
|
+
const row = {
|
|
219
|
+
time: opts.now instanceof Date ? opts.now.toISOString().replace(/\.\d{3}Z$/, "Z") : opts.now ? String(opts.now) : new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
220
|
+
provider,
|
|
221
|
+
model: opts.model == null || String(opts.model).trim() === "" ? "-" : String(opts.model),
|
|
222
|
+
effort: opts.effort == null || String(opts.effort).trim() === "" ? "-" : String(opts.effort),
|
|
223
|
+
session,
|
|
224
|
+
cwd: oneLine(opts.cwd),
|
|
225
|
+
work: chosen.work,
|
|
226
|
+
sticky: chosen.sticky,
|
|
227
|
+
pid: Number(opts.pid),
|
|
228
|
+
start: String(opts.start),
|
|
229
|
+
boot: String(opts.boot ?? ""),
|
|
230
|
+
};
|
|
231
|
+
atomicWrite(file, `${JSON.stringify(row)}\n`);
|
|
232
|
+
sweep({ home, boot: row.boot, probe: opts.probe });
|
|
233
|
+
if (!fs.existsSync(file)) return null;
|
|
234
|
+
return row;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function lines(opts = {}) {
|
|
238
|
+
const home = opts.home ?? boardHome(opts.env);
|
|
239
|
+
const boot = opts.boot ?? readBootId();
|
|
240
|
+
return sweep({ home, boot, probe: opts.probe }).map(formatLine);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function digest(opts = {}) {
|
|
244
|
+
const home = opts.home ?? boardHome(opts.env);
|
|
245
|
+
const boot = opts.boot ?? readBootId();
|
|
246
|
+
const selfP = field(opts.provider);
|
|
247
|
+
const selfS = field(opts.session);
|
|
248
|
+
return sweep({ home, boot, probe: opts.probe })
|
|
249
|
+
.filter((row) => !(field(row.provider) === selfP && field(row.session) === selfS))
|
|
250
|
+
.sort((a, b) => String(b.time).localeCompare(String(a.time)))
|
|
251
|
+
.slice(0, opts.cap ?? 8)
|
|
252
|
+
.map(formatLine)
|
|
253
|
+
.join("\n");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function providerOf(data, env) {
|
|
257
|
+
if (env.GRUNT_BOARD_PROVIDER) return env.GRUNT_BOARD_PROVIDER;
|
|
258
|
+
if (env.GROK_WORKSPACE_ROOT || env.GROK_SESSION_ID || env.GROK_HOOK_EVENT) return "grok";
|
|
259
|
+
if (env.CLAUDE_PROJECT_DIR) return "claude";
|
|
260
|
+
if (data && data.model) return "codex";
|
|
261
|
+
return "-";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function modelOf(data) {
|
|
265
|
+
if (!data || data.model == null || String(data.model).trim() === "") return "-";
|
|
266
|
+
return String(data.model);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function effortOf(data, env) {
|
|
270
|
+
const level = data && data.effort && typeof data.effort === "object" ? data.effort.level : data?.effort;
|
|
271
|
+
if (level != null && String(level).trim() !== "") return String(level);
|
|
272
|
+
if (env.CLAUDE_EFFORT && String(env.CLAUDE_EFFORT).trim() !== "") return String(env.CLAUDE_EFFORT);
|
|
273
|
+
return "-";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function sessionOf(data, env) {
|
|
277
|
+
const value = (data && (data.session_id || data.sessionId)) || env.GROK_SESSION_ID || "";
|
|
278
|
+
return String(value).trim();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function cwdOf(data, env) {
|
|
282
|
+
return (
|
|
283
|
+
env.GROK_WORKSPACE_ROOT ||
|
|
284
|
+
(data && (data.workspaceRoot || data.workspace_root || data.cwd)) ||
|
|
285
|
+
env.CLAUDE_PROJECT_DIR ||
|
|
286
|
+
process.cwd()
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function promptOf(data) {
|
|
291
|
+
if (!data || typeof data !== "object") return "";
|
|
292
|
+
return String(data.prompt ?? data.userPrompt ?? data.user_prompt ?? "");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function recordHook(data, env = process.env, deps = {}) {
|
|
296
|
+
try {
|
|
297
|
+
const session = sessionOf(data, env);
|
|
298
|
+
if (!session || session === "default") return null;
|
|
299
|
+
const ident = deps.identity || hostIdentity({ probe: deps.probe || probeProcess });
|
|
300
|
+
if (!ident) return null;
|
|
301
|
+
const boot = deps.boot || readBootId();
|
|
302
|
+
return note({
|
|
303
|
+
home: boardHome(env),
|
|
304
|
+
env,
|
|
305
|
+
provider: providerOf(data, env),
|
|
306
|
+
model: modelOf(data),
|
|
307
|
+
effort: effortOf(data, env),
|
|
308
|
+
session,
|
|
309
|
+
cwd: cwdOf(data, env),
|
|
310
|
+
work: promptOf(data),
|
|
311
|
+
pid: ident.pid,
|
|
312
|
+
start: ident.start,
|
|
313
|
+
boot,
|
|
314
|
+
now: deps.now,
|
|
315
|
+
probe: deps.probe,
|
|
316
|
+
});
|
|
317
|
+
} catch {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function otherLines(data, env = process.env, deps = {}) {
|
|
323
|
+
try {
|
|
324
|
+
return digest({
|
|
325
|
+
home: boardHome(env),
|
|
326
|
+
env,
|
|
327
|
+
provider: providerOf(data, env),
|
|
328
|
+
session: sessionOf(data, env),
|
|
329
|
+
boot: deps.boot || readBootId(),
|
|
330
|
+
probe: deps.probe,
|
|
331
|
+
});
|
|
332
|
+
} catch {
|
|
333
|
+
return "";
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function parseArgs(argv) {
|
|
338
|
+
const out = { set: false, work: null, home: null };
|
|
339
|
+
for (let i = 0; i < argv.length; i++) {
|
|
340
|
+
const arg = argv[i];
|
|
341
|
+
if (arg === "set") out.set = true;
|
|
342
|
+
else if (arg === "--work") out.work = argv[++i] ?? "";
|
|
343
|
+
else if (arg.startsWith("--work=")) out.work = arg.slice("--work=".length);
|
|
344
|
+
else if (arg === "--home") out.home = argv[++i] ?? "";
|
|
345
|
+
else if (arg.startsWith("--home=")) out.home = arg.slice("--home=".length);
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function main() {
|
|
351
|
+
try {
|
|
352
|
+
const args = parseArgs(process.argv.slice(2));
|
|
353
|
+
const env = { ...process.env };
|
|
354
|
+
if (args.home) env.GRUNT_BOARD_HOME = args.home;
|
|
355
|
+
const home = boardHome(env);
|
|
356
|
+
if (args.set) {
|
|
357
|
+
const ident = hostIdentity();
|
|
358
|
+
if (!ident) return 0;
|
|
359
|
+
const provider = providerOf({}, env);
|
|
360
|
+
const session =
|
|
361
|
+
env.GRUNT_BOARD_SESSION || env.GROK_SESSION_ID || env.CLAUDE_SESSION_ID || String(ident.pid);
|
|
362
|
+
note({
|
|
363
|
+
home,
|
|
364
|
+
env,
|
|
365
|
+
provider: provider === "-" ? "antigravity" : provider,
|
|
366
|
+
model: "-",
|
|
367
|
+
effort: env.CLAUDE_EFFORT || "-",
|
|
368
|
+
session,
|
|
369
|
+
cwd: cwdOf({}, env),
|
|
370
|
+
work: args.work ?? "",
|
|
371
|
+
forceWork: true,
|
|
372
|
+
pid: ident.pid,
|
|
373
|
+
start: ident.start,
|
|
374
|
+
boot: readBootId(),
|
|
375
|
+
});
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
378
|
+
const printed = lines({ home, env });
|
|
379
|
+
if (printed.length) process.stdout.write(`${printed.join("\n")}\n`);
|
|
380
|
+
return 0;
|
|
381
|
+
} catch {
|
|
382
|
+
return 0;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
387
|
+
const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
|
388
|
+
if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
|
|
389
|
+
process.exit(main());
|
|
390
|
+
}
|
package/scripts/doctor.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
detectPackageManager,
|
|
17
17
|
runScriptLine,
|
|
18
18
|
} from "./package-manager.mjs";
|
|
19
|
+
import { selectedPacks } from "./feature-packs.mjs";
|
|
19
20
|
|
|
20
21
|
export const CHROMIUM_BINS = [
|
|
21
22
|
"chromium",
|
|
@@ -229,6 +230,19 @@ function isFile(abs) {
|
|
|
229
230
|
}
|
|
230
231
|
}
|
|
231
232
|
|
|
233
|
+
export function jevDoctorStatus({ env = process.env, home = os.homedir() } = {}) {
|
|
234
|
+
if (String(env.TYPESAFE_API_KEY || "").trim()) return { ok: true, extra: "env" };
|
|
235
|
+
try {
|
|
236
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(home, ".grunt", "jev.json"), "utf8"));
|
|
237
|
+
if (String(parsed && parsed.apiKey ? parsed.apiKey : "").trim()) {
|
|
238
|
+
return { ok: true, extra: "config" };
|
|
239
|
+
}
|
|
240
|
+
} catch {
|
|
241
|
+
/* absent */
|
|
242
|
+
}
|
|
243
|
+
return { ok: false, extra: "" };
|
|
244
|
+
}
|
|
245
|
+
|
|
232
246
|
export function speakDoctorStatus({ env = process.env, home = os.homedir() } = {}) {
|
|
233
247
|
const names = [];
|
|
234
248
|
if (String(env.ELEVENLABS_API_KEY || "").trim()) names.push("elevenlabs");
|
|
@@ -377,20 +391,31 @@ export function runDoctor({
|
|
|
377
391
|
lines.push(rulesync ? row("rulesync", "ok", rulesync) : row("rulesync", "missing"));
|
|
378
392
|
lines.push(lightpanda ? row("lightpanda", "ok", lightpanda) : row("lightpanda", "missing"));
|
|
379
393
|
lines.push(chromium ? row("chromium", "ok", chromium) : row("chromium", "missing"));
|
|
394
|
+
const enabled = new Set(selectedPacks(cwd));
|
|
380
395
|
lines.push(gh ? row("gh", "ok", gh) : row("gh", "missing (optional)"));
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
396
|
+
if (enabled.has("clasp")) {
|
|
397
|
+
lines.push(clasp ? row("clasp", "ok", clasp) : row("clasp", "missing (optional)"));
|
|
398
|
+
}
|
|
399
|
+
if (enabled.has("google-workspace")) {
|
|
400
|
+
const workspace = workspaceDoctorStatus({ home });
|
|
401
|
+
lines.push(
|
|
402
|
+
workspace.ok
|
|
403
|
+
? row("google-workspace", "ok", workspace.extra)
|
|
404
|
+
: row("google-workspace", "missing (optional)"),
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
if (enabled.has("listen")) {
|
|
408
|
+
lines.push(ffmpeg ? row("ffmpeg", "ok", ffmpeg) : row("ffmpeg", "missing (optional)"));
|
|
409
|
+
lines.push(
|
|
410
|
+
whisperCli ? row("whisper-cli", "ok", whisperCli) : row("whisper-cli", "missing (optional)"),
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
if (enabled.has("speak")) {
|
|
414
|
+
const speak = speakDoctorStatus({ env, home });
|
|
415
|
+
lines.push(speak.ok ? row("speak", "ok", speak.extra) : row("speak", "missing (optional)"));
|
|
416
|
+
}
|
|
417
|
+
const jev = jevDoctorStatus({ env, home });
|
|
418
|
+
lines.push(jev.ok ? row("jev", "ok", jev.extra) : row("jev", "missing (optional)"));
|
|
394
419
|
|
|
395
420
|
const missingRequired = REQUIRED.filter((k) => (k === "node" ? !nodeOk : !found[k]));
|
|
396
421
|
if (!pmOk) missingRequired.push("package-manager");
|
|
@@ -444,7 +469,17 @@ export function runDoctor({
|
|
|
444
469
|
return { code: missingRequired.length || missingMaps.length ? 1 : 0, stdout, stderr: "" };
|
|
445
470
|
}
|
|
446
471
|
|
|
447
|
-
|
|
472
|
+
/** Load dest `scripts/lib/env.mjs` `loadEnv` when that file exists next to this script. */
|
|
473
|
+
export async function loadOptionalConsumerEnv(baseHref = import.meta.url) {
|
|
474
|
+
const file = fileURLToPath(new URL("./lib/env.mjs", baseHref));
|
|
475
|
+
if (!fs.existsSync(file)) return false;
|
|
476
|
+
const { loadEnv } = await import(pathToFileURL(file).href);
|
|
477
|
+
if (typeof loadEnv === "function") loadEnv();
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function main() {
|
|
482
|
+
await loadOptionalConsumerEnv();
|
|
448
483
|
const r = runDoctor();
|
|
449
484
|
if (r.stdout) process.stdout.write(r.stdout);
|
|
450
485
|
if (r.stderr) process.stderr.write(r.stderr);
|
|
@@ -454,5 +489,8 @@ function main() {
|
|
|
454
489
|
const thisFile = fileURLToPath(import.meta.url);
|
|
455
490
|
const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
|
456
491
|
if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
|
|
457
|
-
main()
|
|
492
|
+
main().catch((err) => {
|
|
493
|
+
process.stderr.write((err && err.message ? err.message : String(err)) + "\n");
|
|
494
|
+
process.exit(1);
|
|
495
|
+
});
|
|
458
496
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** Optional installer packs. The npm package still ships them. Init writes only the selected ones. */
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const FEATURES_REL = ".rulesync/grunt.features.jsonc";
|
|
6
|
+
|
|
7
|
+
export const OPTIONAL_PACKS = [
|
|
8
|
+
{ name: "google-workspace", skill: "google-workspace", ref: "google-workspace.md", script: "google-workspace.mjs" },
|
|
9
|
+
{ name: "listen", skill: "listen", ref: "listen.md", script: "listen.mjs" },
|
|
10
|
+
{ name: "speak", skill: "speak", ref: "speak.md", script: "speak.mjs" },
|
|
11
|
+
{ name: "clasp", skill: "clasp", ref: "clasp.md", script: "" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export const PACK_OPTIONS = OPTIONAL_PACKS.map((pack) => ({ value: pack.name, label: pack.name }));
|
|
15
|
+
|
|
16
|
+
const SKILL_DIRS = [".rulesync/skills", ".grok/skills", ".claude/skills", ".agents/skills"];
|
|
17
|
+
|
|
18
|
+
function partsOf(rel) {
|
|
19
|
+
return String(rel || "").split(/[/\\]/).filter((part) => part && part !== ".");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function knownSet() {
|
|
23
|
+
return new Set(OPTIONAL_PACKS.map((pack) => pack.name));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function inTableOrder(names) {
|
|
27
|
+
const on = new Set(names);
|
|
28
|
+
return OPTIONAL_PACKS.map((pack) => pack.name).filter((name) => on.has(name));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizePacks(list) {
|
|
32
|
+
const known = knownSet();
|
|
33
|
+
const seen = [];
|
|
34
|
+
for (const raw of list || []) {
|
|
35
|
+
const name = String(raw || "").trim();
|
|
36
|
+
if (!name || seen.includes(name)) continue;
|
|
37
|
+
if (!known.has(name)) throw new Error(`unknown pack ${name}`);
|
|
38
|
+
seen.push(name);
|
|
39
|
+
}
|
|
40
|
+
return inTableOrder(seen);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parsePacksFlag(value) {
|
|
44
|
+
const text = String(value ?? "").trim();
|
|
45
|
+
if (!text || text === "none") return [];
|
|
46
|
+
return normalizePacks(text.split(","));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stripJsonc(text) {
|
|
50
|
+
return String(text)
|
|
51
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
52
|
+
.replace(/^\s*\/\/.*$/gm, "");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** null when the file is missing. Bad JSON or an empty list is `{ packs: [] }`. */
|
|
56
|
+
export function readPacks(dest) {
|
|
57
|
+
const abs = path.join(dest, FEATURES_REL);
|
|
58
|
+
if (!fs.existsSync(abs)) return null;
|
|
59
|
+
try {
|
|
60
|
+
const data = JSON.parse(stripJsonc(fs.readFileSync(abs, "utf8")));
|
|
61
|
+
const raw = Array.isArray(data?.packs) ? data.packs : [];
|
|
62
|
+
const known = knownSet();
|
|
63
|
+
return { packs: inTableOrder(raw.map((name) => String(name).trim()).filter((name) => known.has(name))) };
|
|
64
|
+
} catch {
|
|
65
|
+
return { packs: [] };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function selectedPacks(dest) {
|
|
70
|
+
const saved = readPacks(dest);
|
|
71
|
+
return saved ? saved.packs : [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function writePacks(dest, packs) {
|
|
75
|
+
const selected = normalizePacks(packs);
|
|
76
|
+
const abs = path.join(dest, FEATURES_REL);
|
|
77
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
78
|
+
fs.writeFileSync(abs, `${JSON.stringify({ packs: selected }, null, 2)}\n`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** An array, including `[]`, is an answer. Otherwise use the saved file, or nothing. */
|
|
82
|
+
export function resolvePackSelection(dest, packs) {
|
|
83
|
+
if (Array.isArray(packs)) return { packs: normalizePacks(packs), answered: true };
|
|
84
|
+
const saved = readPacks(dest);
|
|
85
|
+
if (saved) return { packs: saved.packs, answered: false };
|
|
86
|
+
return { packs: [], answered: false };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isUnselectedPackPath(rel, selected) {
|
|
90
|
+
const on = selected instanceof Set ? selected : new Set(selected || []);
|
|
91
|
+
const parts = partsOf(rel);
|
|
92
|
+
if (parts.length >= 3 && parts[1] === "skills") {
|
|
93
|
+
const pack = OPTIONAL_PACKS.find((item) => item.skill === parts[2]);
|
|
94
|
+
if (pack && !on.has(pack.name)) return true;
|
|
95
|
+
}
|
|
96
|
+
if (parts[0] === ".rulesync" && parts[1] === "reference" && parts.length >= 3) {
|
|
97
|
+
const pack = OPTIONAL_PACKS.find((item) => item.ref === parts[2]);
|
|
98
|
+
if (pack && !on.has(pack.name)) return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function unselectedPackRels(selected) {
|
|
104
|
+
const on = selected instanceof Set ? selected : new Set(selected || []);
|
|
105
|
+
const rels = [];
|
|
106
|
+
for (const pack of OPTIONAL_PACKS) {
|
|
107
|
+
if (on.has(pack.name)) continue;
|
|
108
|
+
for (const dir of SKILL_DIRS) rels.push(`${dir}/${pack.skill}`);
|
|
109
|
+
if (pack.ref) rels.push(`.rulesync/reference/${pack.ref}`);
|
|
110
|
+
if (pack.script) rels.push(`scripts/${pack.script}`);
|
|
111
|
+
}
|
|
112
|
+
return rels;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function unselectedRefNames(selected) {
|
|
116
|
+
const on = selected instanceof Set ? selected : new Set(selected || []);
|
|
117
|
+
return new Set(OPTIONAL_PACKS.filter((pack) => pack.ref && !on.has(pack.name)).map((pack) => pack.ref));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function unselectedScriptNames(selected) {
|
|
121
|
+
const on = selected instanceof Set ? selected : new Set(selected || []);
|
|
122
|
+
return new Set(OPTIONAL_PACKS.filter((pack) => pack.script && !on.has(pack.name)).map((pack) => pack.script));
|
|
123
|
+
}
|
package/scripts/guarded-md.mjs
CHANGED
|
@@ -111,6 +111,34 @@ export function mergeGuardedContent(existingText, gruntBody) {
|
|
|
111
111
|
return composeGuardedMarkdown(gruntBody, user, nl);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/** `##` blocks in `existing` whose titles are not in `incoming`. */
|
|
115
|
+
export function extraH2Sections(existing, incoming) {
|
|
116
|
+
const incomingTitles = new Set(
|
|
117
|
+
[...String(incoming ?? "").matchAll(/^## (.+)$/gm)].map((m) => m[1].trim()),
|
|
118
|
+
);
|
|
119
|
+
const text = String(existing ?? "").replace(/\r\n/g, "\n");
|
|
120
|
+
const matches = [...text.matchAll(/^## /gm)];
|
|
121
|
+
if (!matches.length) return "";
|
|
122
|
+
const chunks = [];
|
|
123
|
+
for (let i = 0; i < matches.length; i++) {
|
|
124
|
+
const start = matches[i].index;
|
|
125
|
+
const end = i + 1 < matches.length ? matches[i + 1].index : text.length;
|
|
126
|
+
const block = text.slice(start, end).replace(/(?:\n)+$/, "");
|
|
127
|
+
const title = block.match(/^## (.+)$/m)?.[1].trim();
|
|
128
|
+
if (title && !incomingTitles.has(title)) chunks.push(block);
|
|
129
|
+
}
|
|
130
|
+
return chunks.join("\n\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Grunt body plus consumer-only `##` sections. No sentinels (INDEX inlines law.md). */
|
|
134
|
+
export function mergeReferenceMarkdown(existing, incoming) {
|
|
135
|
+
const src = String(incoming ?? "");
|
|
136
|
+
const extras = extraH2Sections(existing ?? "", src);
|
|
137
|
+
const base = src.replace(/(?:\r?\n)+$/, "");
|
|
138
|
+
if (!extras.trim()) return `${base}\n`;
|
|
139
|
+
return `${base}\n\n${extras.replace(/(?:\n)+$/, "")}\n`;
|
|
140
|
+
}
|
|
141
|
+
|
|
114
142
|
export function writeMergedGuardedFile(destPath, gruntBody) {
|
|
115
143
|
if (!fs.existsSync(destPath)) {
|
|
116
144
|
fs.writeFileSync(destPath, mergeGuardedContent("", gruntBody));
|