@lovrozagar/grunt 0.1.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/agents/grunt.md +36 -0
- package/.agents/agents/implementer.md +40 -0
- package/.agents/agents/orchestrator.md +50 -0
- package/.agents/agents/thinker.md +27 -0
- package/.agents/hooks.json +26 -0
- package/.agents/mcp_config.json +3 -0
- package/.claude/agents/grunt.md +44 -0
- package/.claude/agents/implementer.md +46 -0
- package/.claude/agents/orchestrator.md +55 -0
- package/.claude/agents/thinker.md +34 -0
- package/.claude/settings.json +36 -0
- package/.codex/agents/grunt.toml +30 -0
- package/.codex/agents/implementer.toml +34 -0
- package/.codex/agents/orchestrator.toml +45 -0
- package/.codex/agents/thinker.toml +23 -0
- package/.codex/config.toml +2 -0
- package/.codex/hooks.json +26 -0
- package/.grok/agents/grunt.md +31 -0
- package/.grok/agents/implementer.md +35 -0
- package/.grok/agents/orchestrator.md +47 -0
- package/.grok/agents/thinker.md +25 -0
- package/.grok/config.toml +6 -0
- package/.grok/global-settings.toml +35 -0
- package/.grok/hooks/orchestrate-parent.js +476 -0
- package/.grok/hooks/orchestrate-parent.json +60 -0
- package/.grok/hooks/rtk-grok.js +137 -0
- package/.grok/hooks/rtk.json +16 -0
- package/.grok/hooks/rulesync.json +3 -0
- package/.grok/parent.md +1 -0
- package/.grok/roles/grunt.toml +5 -0
- package/.grok/roles/implementer.toml +5 -0
- package/.grok/roles/thinker.toml +5 -0
- package/.grok/skills/explain/SKILL.md +15 -0
- package/.grok/skills/implement-plan/SKILL.md +91 -0
- package/.grok/skills/parent/SKILL.md +12 -0
- package/.grok/skills/shared/plan-format.md +158 -0
- package/.grok/skills/write-plan/SKILL.md +84 -0
- package/.mcp.json +3 -0
- package/.rulesync/global-settings/antigravity.json +1 -0
- package/.rulesync/global-settings/claude.json +1 -0
- package/.rulesync/global-settings/codex.toml +1 -0
- package/.rulesync/global-settings/manifest.json +37 -0
- package/.rulesync/hooks.jsonc +22 -0
- package/.rulesync/mcp-policy.jsonc +15 -0
- package/.rulesync/reference/cascade.md +85 -0
- package/.rulesync/reference/hooks.md +19 -0
- package/.rulesync/reference/map.md +57 -0
- package/.rulesync/reference/rtk.md +23 -0
- package/.rulesync/rules/CLAUDE.md +13 -0
- package/.rulesync/rules/overview.md +23 -0
- package/.rulesync/skills/explain/SKILL.md +15 -0
- package/.rulesync/skills/parent/SKILL.md +12 -0
- package/.rulesync/skills/terse/SKILL.md +8 -0
- package/.rulesync/subagents/grunt.md +51 -0
- package/.rulesync/subagents/implementer.md +54 -0
- package/.rulesync/subagents/orchestrator.md +69 -0
- package/.rulesync/subagents/thinker.md +44 -0
- package/AGENTS.md +12 -0
- package/CLAUDE.md +7 -0
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/bin/grunt.js +4 -0
- package/cli/grunt.mjs +54 -0
- package/cli/grunt.test.ts +117 -0
- package/cli/init.mjs +113 -0
- package/cli/init.test.ts +268 -0
- package/package.json +77 -0
- package/scripts/check-globals.mjs +98 -0
- package/scripts/emit-mcp-policy.mjs +434 -0
- package/scripts/gate-fat-tools.mjs +485 -0
- package/scripts/grunt-job.mjs +482 -0
- package/scripts/parse-need.mjs +75 -0
- package/scripts/persist-plan.mjs +309 -0
- package/scripts/purge-global-mcps.mjs +291 -0
- package/scripts/scrub-spawn-prompt.mjs +219 -0
- package/scripts/scrub-text +58 -0
- package/scripts/scrub-text-lib.mjs +210 -0
- package/scripts/sync-global-settings.mjs +239 -0
- package/scripts/telemetry.mjs +38 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Allocate/slugify/validate/write a local plan under .tmp/plans/. */
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
export const PLAN_DIR = ".tmp/plans";
|
|
8
|
+
/** Old unstamped names and new `{serial}-{slug}-{YYYYMMDDTHHMMSSZ}.md`. */
|
|
9
|
+
export const FILENAME_RE =
|
|
10
|
+
/^[0-9]+-[a-z0-9]+(-[a-z0-9]+)*(-\d{8}T\d{6}Z)?\.md$/;
|
|
11
|
+
export const SERIAL_LINE_RE = /^serial: [1-9][0-9]*$/;
|
|
12
|
+
export const STATUS_RE = /^(ready|in-progress|done|blocked)$/;
|
|
13
|
+
export const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
14
|
+
export const DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
15
|
+
export const CREATED_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$/;
|
|
16
|
+
export const FILENAME_STAMP_RE = /-\d{8}T\d{6}Z$/;
|
|
17
|
+
export const REQUIRED_H2 = [
|
|
18
|
+
"Goal",
|
|
19
|
+
"Context",
|
|
20
|
+
"Constraints",
|
|
21
|
+
"Watch-outs",
|
|
22
|
+
"Steps",
|
|
23
|
+
"Verify",
|
|
24
|
+
];
|
|
25
|
+
const BOX_RE = /^( {0,2})(\d+(?:\.\d+){0,2}) \[([ xX])\] (.+)$/;
|
|
26
|
+
const LEAF_ID_RE = /^\d+\.\d+(?:\.\d+)?$/;
|
|
27
|
+
|
|
28
|
+
export function slugify(name) {
|
|
29
|
+
let s = String(name || "")
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
32
|
+
.replace(/-+/g, "-")
|
|
33
|
+
.replace(/^-+|-+$/g, "");
|
|
34
|
+
if (s.length > 50) {
|
|
35
|
+
s = s.slice(0, 50).replace(/-+$/g, "");
|
|
36
|
+
}
|
|
37
|
+
if (!s) s = "unnamed";
|
|
38
|
+
if (s.endsWith(".lock")) {
|
|
39
|
+
s = s.slice(0, -5).replace(/-+$/g, "") || "unnamed";
|
|
40
|
+
}
|
|
41
|
+
return s;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function utcDateTime(d = new Date()) {
|
|
45
|
+
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function utcDate(d = new Date()) {
|
|
49
|
+
return utcDateTime(d).slice(0, 10);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function compactCreatedStamp(created) {
|
|
53
|
+
return String(created).replace(/[-:]/g, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function slugFromFilename(filename) {
|
|
57
|
+
return String(filename)
|
|
58
|
+
.replace(/\.md$/, "")
|
|
59
|
+
.replace(/^[0-9]+-/, "")
|
|
60
|
+
.replace(FILENAME_STAMP_RE, "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function extractPlanName(content) {
|
|
64
|
+
const m = String(content).match(/^\s*PLAN_NAME:\s*(.+?)\s*$/m);
|
|
65
|
+
return m ? m[1].trim() : "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function stripPlanName(content) {
|
|
69
|
+
return String(content).replace(/^\s*PLAN_NAME:\s*.+\s*\n/, "");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function extractH1(content) {
|
|
73
|
+
const m = String(content).match(/^#\s+(.+?)\s*$/m);
|
|
74
|
+
return m ? m[1].trim() : "";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function nextSerial(plansDir) {
|
|
78
|
+
let next = 1;
|
|
79
|
+
if (!fs.existsSync(plansDir)) return next;
|
|
80
|
+
for (const b of fs.readdirSync(plansDir)) {
|
|
81
|
+
if (!b.endsWith(".md")) continue;
|
|
82
|
+
const m = b.match(/^([0-9]+)-/);
|
|
83
|
+
if (!m) continue;
|
|
84
|
+
const n = parseInt(m[1], 10);
|
|
85
|
+
if (Number.isFinite(n) && n >= next) next = n + 1;
|
|
86
|
+
}
|
|
87
|
+
return next;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function ensureGitignore(workspaceRoot) {
|
|
91
|
+
const p = path.join(workspaceRoot, ".gitignore");
|
|
92
|
+
let cur = "";
|
|
93
|
+
try {
|
|
94
|
+
cur = fs.readFileSync(p, "utf8");
|
|
95
|
+
} catch {
|
|
96
|
+
fs.writeFileSync(p, ".tmp/\n");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (/(?:^|\n)\.tmp\/(?:\n|$)/.test(cur)) return;
|
|
100
|
+
const next = cur.endsWith("\n") || !cur ? cur : cur + "\n";
|
|
101
|
+
fs.writeFileSync(p, next + ".tmp/\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function stripFrontmatter(text) {
|
|
105
|
+
const s = String(text).replace(/^\uFEFF/, "");
|
|
106
|
+
if (!s.startsWith("---\n")) return s;
|
|
107
|
+
const end = s.indexOf("\n---\n", 4);
|
|
108
|
+
if (end === -1) return s;
|
|
109
|
+
return s.slice(end + 5);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function injectFrontmatter({ serial, slug, source, body, created }) {
|
|
113
|
+
const src = String(source || "").slice(0, 120);
|
|
114
|
+
let rest = stripFrontmatter(body).replace(/^\s+/, "");
|
|
115
|
+
if (!rest.startsWith("# ")) {
|
|
116
|
+
rest = `# ${slug}\n\n` + rest;
|
|
117
|
+
} else {
|
|
118
|
+
rest = rest.replace(/^# .+/, `# ${slug}`);
|
|
119
|
+
}
|
|
120
|
+
return `---
|
|
121
|
+
serial: ${serial}
|
|
122
|
+
name: ${slug}
|
|
123
|
+
status: ready
|
|
124
|
+
created: ${created || utcDateTime()}
|
|
125
|
+
source: ${JSON.stringify(src)}
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
${rest}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function sectionBodies(text) {
|
|
132
|
+
const h2 = [...text.matchAll(/^## (.+)$/gm)].map((m) => ({
|
|
133
|
+
title: m[1].trim(),
|
|
134
|
+
index: m.index,
|
|
135
|
+
}));
|
|
136
|
+
const bodies = {};
|
|
137
|
+
for (let i = 0; i < h2.length; i++) {
|
|
138
|
+
const start = h2[i].index + h2[i].title.length + 4;
|
|
139
|
+
const end = i + 1 < h2.length ? h2[i + 1].index : text.length;
|
|
140
|
+
bodies[h2[i].title] = text.slice(start, end);
|
|
141
|
+
}
|
|
142
|
+
return { order: h2.map((h) => h.title), bodies };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseFrontmatter(text) {
|
|
146
|
+
if (!text.startsWith("---\n")) return { error: "missing frontmatter" };
|
|
147
|
+
const end = text.indexOf("\n---\n", 4);
|
|
148
|
+
if (end === -1) return { error: "unterminated frontmatter" };
|
|
149
|
+
const raw = text.slice(4, end);
|
|
150
|
+
const map = {};
|
|
151
|
+
for (const line of raw.split("\n")) {
|
|
152
|
+
const m = line.match(/^([a-z]+):(?:\s+(.*))?$/);
|
|
153
|
+
if (!m) continue;
|
|
154
|
+
let v = m[2] ?? "";
|
|
155
|
+
if (v.startsWith('"') && v.endsWith('"')) {
|
|
156
|
+
try {
|
|
157
|
+
v = JSON.parse(v);
|
|
158
|
+
} catch {
|
|
159
|
+
v = v.slice(1, -1);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
map[m[1]] = v;
|
|
163
|
+
}
|
|
164
|
+
return { map, body: text.slice(end + 5) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function validatePlan(filename, text) {
|
|
168
|
+
const errors = [];
|
|
169
|
+
if (!FILENAME_RE.test(filename)) errors.push("filename");
|
|
170
|
+
const fm = parseFrontmatter(text);
|
|
171
|
+
if (fm.error) {
|
|
172
|
+
errors.push(fm.error);
|
|
173
|
+
return errors;
|
|
174
|
+
}
|
|
175
|
+
const { map, body } = fm;
|
|
176
|
+
const serialLine = text.split("\n").find((l) => l.startsWith("serial:"));
|
|
177
|
+
if (!serialLine || !SERIAL_LINE_RE.test(serialLine)) errors.push("serial line");
|
|
178
|
+
for (const k of ["serial", "name", "status", "created", "source"]) {
|
|
179
|
+
if (map[k] == null || map[k] === "") errors.push(`missing ${k}`);
|
|
180
|
+
}
|
|
181
|
+
if (map.status && !STATUS_RE.test(map.status)) errors.push("status");
|
|
182
|
+
if (map.created && !CREATED_RE.test(map.created)) errors.push("created");
|
|
183
|
+
if (typeof map.source === "string" && map.source.length > 120) {
|
|
184
|
+
errors.push("source length");
|
|
185
|
+
}
|
|
186
|
+
const slugFromName = map.name;
|
|
187
|
+
if (slugFromName && slugFromFilename(filename) !== slugFromName) {
|
|
188
|
+
errors.push("name/filename slug");
|
|
189
|
+
}
|
|
190
|
+
if (!body.trim().startsWith(`# ${slugFromName}`)) errors.push("h1 slug");
|
|
191
|
+
const { order, bodies } = sectionBodies(body);
|
|
192
|
+
const firstSix = order.slice(0, REQUIRED_H2.length);
|
|
193
|
+
if (REQUIRED_H2.some((h, i) => firstSix[i] !== h)) errors.push("heading order");
|
|
194
|
+
const extra = order.filter((h) => !REQUIRED_H2.includes(h));
|
|
195
|
+
if (extra.length) errors.push("extra h2");
|
|
196
|
+
const goal = (bodies.Goal || "").trim();
|
|
197
|
+
const sentences = goal.split(/(?<=[.!?])(?:\s+|$)/).filter((s) => s.trim());
|
|
198
|
+
if (!goal || sentences.length < 1 || sentences.length > 3) errors.push("goal");
|
|
199
|
+
for (const h of ["Context", "Constraints", "Watch-outs"]) {
|
|
200
|
+
const b = (bodies[h] || "").trim();
|
|
201
|
+
if (!b) errors.push(`${h} empty`);
|
|
202
|
+
else if (!/^(?:[-*] |\(none\))/m.test(b)) errors.push(`${h} bullets`);
|
|
203
|
+
}
|
|
204
|
+
const boxes = [];
|
|
205
|
+
for (const line of text.split("\n")) {
|
|
206
|
+
const m = line.match(BOX_RE);
|
|
207
|
+
if (!m) continue;
|
|
208
|
+
if (m[3] !== " ") errors.push("fresh [x]");
|
|
209
|
+
boxes.push({ id: m[2], text: m[4] });
|
|
210
|
+
}
|
|
211
|
+
const leaves = boxes.filter((b) => LEAF_ID_RE.test(b.id));
|
|
212
|
+
if (leaves.length < 1) errors.push("no N.M leaf");
|
|
213
|
+
const phases = boxes.filter((b) => /^\d+$/.test(b.id));
|
|
214
|
+
for (const p of phases) {
|
|
215
|
+
if (!boxes.some((b) => b.id.startsWith(p.id + "."))) errors.push(`phase ${p.id} leaf`);
|
|
216
|
+
}
|
|
217
|
+
if (phases.length) {
|
|
218
|
+
const last = phases[phases.length - 1];
|
|
219
|
+
if (!/^Verify\b/i.test(last.text) && last.id !== String(phases.length)) {
|
|
220
|
+
errors.push("verify last phase");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return [...new Set(errors)];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function persistPlan({
|
|
227
|
+
workspaceRoot,
|
|
228
|
+
content,
|
|
229
|
+
source,
|
|
230
|
+
created,
|
|
231
|
+
} = {}) {
|
|
232
|
+
const ws = workspaceRoot || process.cwd();
|
|
233
|
+
const raw = String(content || "");
|
|
234
|
+
const planName = extractPlanName(raw) || extractH1(stripFrontmatter(stripPlanName(raw)));
|
|
235
|
+
const slug = slugify(planName);
|
|
236
|
+
const src =
|
|
237
|
+
source ||
|
|
238
|
+
(raw.match(/^\s*source:\s*(.+)$/m) ? undefined : planName);
|
|
239
|
+
const body = stripPlanName(raw);
|
|
240
|
+
const plansDir = path.join(ws, PLAN_DIR);
|
|
241
|
+
fs.mkdirSync(plansDir, { recursive: true });
|
|
242
|
+
const createdAt =
|
|
243
|
+
created && DATETIME_RE.test(created) ? created : utcDateTime();
|
|
244
|
+
const stamp = compactCreatedStamp(createdAt);
|
|
245
|
+
let serial = nextSerial(plansDir);
|
|
246
|
+
let filename = `${serial}-${slug}-${stamp}.md`;
|
|
247
|
+
let dest = path.join(plansDir, filename);
|
|
248
|
+
let tries = 0;
|
|
249
|
+
while (fs.existsSync(dest) && tries < 5) {
|
|
250
|
+
serial += 1;
|
|
251
|
+
filename = `${serial}-${slug}-${stamp}.md`;
|
|
252
|
+
dest = path.join(plansDir, filename);
|
|
253
|
+
tries += 1;
|
|
254
|
+
}
|
|
255
|
+
if (fs.existsSync(dest)) {
|
|
256
|
+
return { ok: false, error: "serial collision" };
|
|
257
|
+
}
|
|
258
|
+
const goalMatch = stripFrontmatter(body).match(/## Goal\n([\s\S]*?)\n## /);
|
|
259
|
+
const sourceText = String(
|
|
260
|
+
source ||
|
|
261
|
+
planName ||
|
|
262
|
+
(goalMatch ? goalMatch[1].trim().replace(/\s+/g, " ") : slug),
|
|
263
|
+
).slice(0, 120);
|
|
264
|
+
const full = injectFrontmatter({
|
|
265
|
+
serial,
|
|
266
|
+
slug,
|
|
267
|
+
source: sourceText,
|
|
268
|
+
body,
|
|
269
|
+
created: createdAt,
|
|
270
|
+
});
|
|
271
|
+
const errors = validatePlan(filename, full);
|
|
272
|
+
if (errors.length) {
|
|
273
|
+
return { ok: false, error: errors.join("; "), errors };
|
|
274
|
+
}
|
|
275
|
+
ensureGitignore(ws);
|
|
276
|
+
fs.writeFileSync(dest, full);
|
|
277
|
+
return { ok: true, path: dest, serial, slug, content: full, filename };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function main() {
|
|
281
|
+
try {
|
|
282
|
+
const wsIdx = process.argv.indexOf("--workspace");
|
|
283
|
+
const workspaceRoot =
|
|
284
|
+
wsIdx >= 0 ? process.argv[wsIdx + 1] : process.cwd();
|
|
285
|
+
const content = fs.readFileSync(0, "utf8");
|
|
286
|
+
const result = persistPlan({ workspaceRoot, content });
|
|
287
|
+
if (!result.ok) {
|
|
288
|
+
process.stderr.write((result.error || "invalid plan") + "\n");
|
|
289
|
+
return 1;
|
|
290
|
+
}
|
|
291
|
+
process.stdout.write(
|
|
292
|
+
JSON.stringify({
|
|
293
|
+
serial: result.serial,
|
|
294
|
+
path: result.path,
|
|
295
|
+
filename: result.filename,
|
|
296
|
+
}) + "\n",
|
|
297
|
+
);
|
|
298
|
+
return 0;
|
|
299
|
+
} catch (err) {
|
|
300
|
+
process.stderr.write(String(err && err.message ? err.message : err) + "\n");
|
|
301
|
+
return 1;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
306
|
+
const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
|
307
|
+
if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
|
|
308
|
+
process.exit(main());
|
|
309
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Purge stubborn global MCP sources under $HOME (dry-run default). Never project MCP. */
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
8
|
+
|
|
9
|
+
const PLUGIN_IDS = ["cloudflare", "stripe"];
|
|
10
|
+
const DISABLE_MCP = ["cloudflare-docs", "MCP_DOCKER", "stripe"];
|
|
11
|
+
const CURSOR_SERVER = "MCP_DOCKER";
|
|
12
|
+
const STRIPE_PLUGIN = "stripe";
|
|
13
|
+
|
|
14
|
+
function isPlainObject(v) {
|
|
15
|
+
return v != null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** HOME, else USERPROFILE (Windows / env sim), else os.homedir(). */
|
|
19
|
+
export function resolveHome(env = process.env) {
|
|
20
|
+
return env.HOME || env.USERPROFILE || os.homedir();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseArgv(argv) {
|
|
24
|
+
const args = Array.isArray(argv) ? argv : [];
|
|
25
|
+
let apply = false;
|
|
26
|
+
for (const a of args) {
|
|
27
|
+
if (a === "--apply") {
|
|
28
|
+
apply = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
return { ok: false, error: `unknown flag: ${a}` };
|
|
32
|
+
}
|
|
33
|
+
return { ok: true, apply };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function grokConfigPath(home) {
|
|
37
|
+
return path.join(path.resolve(home), ".grok", "config.toml");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function cursorMcpPath(home) {
|
|
41
|
+
return path.join(path.resolve(home), ".cursor", "mcp.json");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function claudePluginsPath(home) {
|
|
45
|
+
return path.join(path.resolve(home), ".claude", "plugins", "installed_plugins.json");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isStripePluginKey(k) {
|
|
49
|
+
return String(k).split("@")[0] === STRIPE_PLUGIN;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function strList(v, label) {
|
|
53
|
+
if (v == null) return [];
|
|
54
|
+
if (!Array.isArray(v)) throw new Error(`unreadable dest: ${label}`);
|
|
55
|
+
return v.map((x) => String(x));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function dedupe(list) {
|
|
59
|
+
const out = [];
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
for (const x of list) {
|
|
62
|
+
if (seen.has(x)) continue;
|
|
63
|
+
seen.add(x);
|
|
64
|
+
out.push(x);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writeText(abs, body) {
|
|
70
|
+
const text = body.endsWith("\n") ? body : body + "\n";
|
|
71
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
72
|
+
fs.writeFileSync(abs, text);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function reportLine(row) {
|
|
76
|
+
return `${row.action} ${row.path} ${row.detail}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function planGrok(home) {
|
|
80
|
+
const abs = grokConfigPath(home);
|
|
81
|
+
let raw;
|
|
82
|
+
try {
|
|
83
|
+
raw = fs.readFileSync(abs, "utf8");
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err && err.code === "ENOENT") {
|
|
86
|
+
return { abs, skip: true, detail: "noop (missing)", obj: null };
|
|
87
|
+
}
|
|
88
|
+
throw new Error(`unreadable dest: ${abs}`);
|
|
89
|
+
}
|
|
90
|
+
let obj;
|
|
91
|
+
try {
|
|
92
|
+
const v = parseToml(raw);
|
|
93
|
+
if (!isPlainObject(v)) throw new Error("not a table");
|
|
94
|
+
obj = v;
|
|
95
|
+
} catch {
|
|
96
|
+
throw new Error(`unreadable dest: ${abs}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const next = { ...obj };
|
|
100
|
+
const details = [];
|
|
101
|
+
let changed = false;
|
|
102
|
+
|
|
103
|
+
const hadPlugins = isPlainObject(obj.plugins);
|
|
104
|
+
if (obj.plugins != null && !hadPlugins) {
|
|
105
|
+
throw new Error(`unreadable dest: ${abs}`);
|
|
106
|
+
}
|
|
107
|
+
const plugins = hadPlugins ? { ...obj.plugins } : {};
|
|
108
|
+
const enabled = strList(plugins.enabled, abs);
|
|
109
|
+
const disabled = strList(plugins.disabled, abs);
|
|
110
|
+
const nextEnabled = enabled.filter((n) => !PLUGIN_IDS.includes(n));
|
|
111
|
+
const nextDisabled = dedupe([...disabled, ...PLUGIN_IDS]);
|
|
112
|
+
const enabledChanged = nextEnabled.join("\0") !== enabled.join("\0");
|
|
113
|
+
const disabledChanged = nextDisabled.join("\0") !== disabled.join("\0");
|
|
114
|
+
if (enabledChanged) {
|
|
115
|
+
const removed = enabled.filter((n) => PLUGIN_IDS.includes(n));
|
|
116
|
+
details.push(`remove ${removed.join(", ")} from [plugins].enabled`);
|
|
117
|
+
changed = true;
|
|
118
|
+
}
|
|
119
|
+
if (disabledChanged) {
|
|
120
|
+
const added = PLUGIN_IDS.filter((id) => !disabled.includes(id));
|
|
121
|
+
details.push(`add ${added.join(", ")} to [plugins].disabled`);
|
|
122
|
+
changed = true;
|
|
123
|
+
}
|
|
124
|
+
if (enabledChanged || disabledChanged) {
|
|
125
|
+
if ("enabled" in plugins || enabledChanged) plugins.enabled = nextEnabled;
|
|
126
|
+
plugins.disabled = nextDisabled;
|
|
127
|
+
next.plugins = plugins;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const mcpKey = "disabled_mcp_servers";
|
|
131
|
+
if (next[mcpKey] == null || Array.isArray(next[mcpKey])) {
|
|
132
|
+
const cur = strList(next[mcpKey], abs);
|
|
133
|
+
const mcpNext = dedupe([...cur, ...DISABLE_MCP]);
|
|
134
|
+
if (mcpNext.join("\0") !== cur.join("\0")) {
|
|
135
|
+
const added = mcpNext.filter((x) => !cur.includes(x));
|
|
136
|
+
next[mcpKey] = mcpNext;
|
|
137
|
+
details.push(`add ${added.join(", ")} to ${mcpKey}`);
|
|
138
|
+
changed = true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
abs,
|
|
144
|
+
skip: !changed,
|
|
145
|
+
detail: changed ? details.join("; ") : "noop (already clean)",
|
|
146
|
+
obj: next,
|
|
147
|
+
changed,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function planClaudePlugins(home) {
|
|
152
|
+
const abs = claudePluginsPath(home);
|
|
153
|
+
let raw;
|
|
154
|
+
try {
|
|
155
|
+
raw = fs.readFileSync(abs, "utf8");
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (err && err.code === "ENOENT") {
|
|
158
|
+
return { abs, skip: true, detail: "noop (missing)", json: null };
|
|
159
|
+
}
|
|
160
|
+
throw new Error(`unreadable dest: ${abs}`);
|
|
161
|
+
}
|
|
162
|
+
let obj;
|
|
163
|
+
try {
|
|
164
|
+
obj = JSON.parse(raw);
|
|
165
|
+
} catch {
|
|
166
|
+
throw new Error(`unreadable dest: ${abs}`);
|
|
167
|
+
}
|
|
168
|
+
if (!isPlainObject(obj)) throw new Error(`unreadable dest: ${abs}`);
|
|
169
|
+
const plugins = obj.plugins;
|
|
170
|
+
if (plugins == null) {
|
|
171
|
+
return { abs, skip: true, detail: "noop (already clean)", json: null };
|
|
172
|
+
}
|
|
173
|
+
if (!isPlainObject(plugins)) throw new Error(`unreadable dest: ${abs}`);
|
|
174
|
+
const keys = Object.keys(plugins).filter(isStripePluginKey);
|
|
175
|
+
if (!keys.length) {
|
|
176
|
+
return { abs, skip: true, detail: "noop (already clean)", json: null };
|
|
177
|
+
}
|
|
178
|
+
const nextPlugins = { ...plugins };
|
|
179
|
+
for (const k of keys) delete nextPlugins[k];
|
|
180
|
+
return {
|
|
181
|
+
abs,
|
|
182
|
+
skip: false,
|
|
183
|
+
detail: `remove ${keys.join(", ")} from plugins`,
|
|
184
|
+
json: { ...obj, plugins: nextPlugins },
|
|
185
|
+
changed: true,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function planCursor(home) {
|
|
190
|
+
const abs = cursorMcpPath(home);
|
|
191
|
+
let raw;
|
|
192
|
+
try {
|
|
193
|
+
raw = fs.readFileSync(abs, "utf8");
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (err && err.code === "ENOENT") {
|
|
196
|
+
return { abs, skip: true, detail: "noop (missing)", json: null };
|
|
197
|
+
}
|
|
198
|
+
throw new Error(`unreadable dest: ${abs}`);
|
|
199
|
+
}
|
|
200
|
+
let obj;
|
|
201
|
+
try {
|
|
202
|
+
obj = JSON.parse(raw);
|
|
203
|
+
} catch {
|
|
204
|
+
throw new Error(`unreadable dest: ${abs}`);
|
|
205
|
+
}
|
|
206
|
+
if (!isPlainObject(obj)) throw new Error(`unreadable dest: ${abs}`);
|
|
207
|
+
const servers = obj.mcpServers;
|
|
208
|
+
if (servers == null) {
|
|
209
|
+
return { abs, skip: true, detail: "noop (already clean)", json: null };
|
|
210
|
+
}
|
|
211
|
+
if (!isPlainObject(servers)) throw new Error(`unreadable dest: ${abs}`);
|
|
212
|
+
if (!Object.prototype.hasOwnProperty.call(servers, CURSOR_SERVER)) {
|
|
213
|
+
return { abs, skip: true, detail: "noop (already clean)", json: null };
|
|
214
|
+
}
|
|
215
|
+
const nextServers = { ...servers };
|
|
216
|
+
delete nextServers[CURSOR_SERVER];
|
|
217
|
+
const json = { ...obj, mcpServers: nextServers };
|
|
218
|
+
return {
|
|
219
|
+
abs,
|
|
220
|
+
skip: false,
|
|
221
|
+
detail: `remove ${CURSOR_SERVER} from mcpServers`,
|
|
222
|
+
json,
|
|
223
|
+
changed: true,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function purgeGlobalMcps({ home, apply = false } = {}) {
|
|
228
|
+
const report = [];
|
|
229
|
+
const action = apply ? "apply" : "dry-run";
|
|
230
|
+
try {
|
|
231
|
+
if (!home) throw new Error("HOME required");
|
|
232
|
+
const grok = planGrok(home);
|
|
233
|
+
const cursor = planCursor(home);
|
|
234
|
+
const claude = planClaudePlugins(home);
|
|
235
|
+
if (apply) {
|
|
236
|
+
if (!grok.skip && grok.obj) {
|
|
237
|
+
writeText(grok.abs, stringifyToml(grok.obj));
|
|
238
|
+
}
|
|
239
|
+
if (!cursor.skip && cursor.json) {
|
|
240
|
+
writeText(cursor.abs, JSON.stringify(cursor.json, null, 2));
|
|
241
|
+
}
|
|
242
|
+
if (!claude.skip && claude.json) {
|
|
243
|
+
writeText(claude.abs, JSON.stringify(claude.json, null, 2));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (const row of [
|
|
247
|
+
{ action, path: grok.abs, detail: grok.detail },
|
|
248
|
+
{ action, path: cursor.abs, detail: cursor.detail },
|
|
249
|
+
{ action, path: claude.abs, detail: claude.detail },
|
|
250
|
+
]) {
|
|
251
|
+
report.push({ ...row, line: reportLine(row) });
|
|
252
|
+
}
|
|
253
|
+
return { ok: true, report };
|
|
254
|
+
} catch (err) {
|
|
255
|
+
const error = String(err && err.message ? err.message : err);
|
|
256
|
+
return { ok: false, report, error };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function main() {
|
|
261
|
+
try {
|
|
262
|
+
const parsed = parseArgv(process.argv.slice(2));
|
|
263
|
+
if (!parsed.ok) {
|
|
264
|
+
process.stderr.write((parsed.error || "invalid argv") + "\n");
|
|
265
|
+
return 1;
|
|
266
|
+
}
|
|
267
|
+
const home = resolveHome();
|
|
268
|
+
if (!home) {
|
|
269
|
+
process.stderr.write("HOME required\n");
|
|
270
|
+
return 1;
|
|
271
|
+
}
|
|
272
|
+
const result = purgeGlobalMcps({ home, apply: parsed.apply });
|
|
273
|
+
for (const row of result.report || []) {
|
|
274
|
+
process.stdout.write((row.line || reportLine(row)) + "\n");
|
|
275
|
+
}
|
|
276
|
+
if (!result.ok) {
|
|
277
|
+
process.stderr.write((result.error || "purge failed") + "\n");
|
|
278
|
+
return 1;
|
|
279
|
+
}
|
|
280
|
+
return 0;
|
|
281
|
+
} catch (err) {
|
|
282
|
+
process.stderr.write(String(err && err.message ? err.message : err) + "\n");
|
|
283
|
+
return 1;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
288
|
+
const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
|
|
289
|
+
if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
|
|
290
|
+
process.exit(main());
|
|
291
|
+
}
|