roger-roger 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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -0
  3. package/package.json +45 -0
  4. package/skills/roger-roger/SKILL.md +289 -0
  5. package/skills/roger-roger/herdr-plugin.toml +38 -0
  6. package/skills/roger-roger/scripts/agent.mjs +132 -0
  7. package/skills/roger-roger/scripts/audio.mjs +392 -0
  8. package/skills/roger-roger/scripts/client.mjs +121 -0
  9. package/skills/roger-roger/scripts/daemon.mjs +604 -0
  10. package/skills/roger-roger/scripts/decisions.mjs +158 -0
  11. package/skills/roger-roger/scripts/handlers.mjs +1151 -0
  12. package/skills/roger-roger/scripts/herdr.mjs +140 -0
  13. package/skills/roger-roger/scripts/hooks-codex.mjs +154 -0
  14. package/skills/roger-roger/scripts/hooks-opencode.mjs +167 -0
  15. package/skills/roger-roger/scripts/hooks.mjs +420 -0
  16. package/skills/roger-roger/scripts/inbox.mjs +381 -0
  17. package/skills/roger-roger/scripts/install.mjs +560 -0
  18. package/skills/roger-roger/scripts/lib.mjs +1133 -0
  19. package/skills/roger-roger/scripts/names.mjs +84 -0
  20. package/skills/roger-roger/scripts/progress.mjs +91 -0
  21. package/skills/roger-roger/scripts/protocol.mjs +71 -0
  22. package/skills/roger-roger/scripts/roger-roger.mjs +536 -0
  23. package/skills/roger-roger/scripts/router.mjs +86 -0
  24. package/skills/roger-roger/scripts/sessions.mjs +218 -0
  25. package/skills/roger-roger/scripts/slack.mjs +240 -0
  26. package/skills/roger-roger/scripts/slackapp.mjs +205 -0
  27. package/skills/roger-roger/scripts/slackcli.mjs +144 -0
  28. package/skills/roger-roger/scripts/speaker.mjs +224 -0
  29. package/skills/roger-roger/scripts/speechkey.mjs +106 -0
  30. package/skills/roger-roger/scripts/tray.mjs +128 -0
  31. package/skills/roger-roger/scripts/tts.mjs +275 -0
  32. package/skills/roger-roger/scripts/tui.mjs +465 -0
  33. package/skills/roger-roger/slack/manifest.json +34 -0
  34. package/skills/roger-roger/sounds/alert.wav +0 -0
  35. package/skills/roger-roger/sounds/bubble.wav +0 -0
  36. package/skills/roger-roger/sounds/chime.wav +0 -0
  37. package/skills/roger-roger/sounds/ding.wav +0 -0
  38. package/skills/roger-roger/sounds/marimba.wav +0 -0
  39. package/skills/roger-roger/tray/main.mjs +749 -0
  40. package/skills/roger-roger/tray/panel.html +501 -0
@@ -0,0 +1,420 @@
1
+ // Hooks: the agent's own program telling us something, rather than the model deciding to.
2
+ //
3
+ // The skill covers everything the model says through roger-roger. What it misses is when the agent
4
+ // asks in the terminal instead — Claude Code's own question dialog (AskUserQuestion), or a plan to
5
+ // approve — and sits there waiting for someone who has walked off. Claude Code shows those as
6
+ // prompts, and can run a command of ours around every prompt, so the ping comes from there.
7
+ //
8
+ // Three Claude Code hooks work together:
9
+ //
10
+ // PermissionRequest fires the instant a prompt is about to show, with the tool and its input.
11
+ // We only note it down — the user may well be at the keyboard.
12
+ // Notification (`permission_prompt`) fires once the prompt has sat for about six seconds
13
+ // with nobody typing. That is Claude Code's own "the user is away", so this is
14
+ // where the ping goes out, described with what the first hook noted. Questions
15
+ // always ping; leave to run a command only with `setup --permission-pings on`.
16
+ // PostToolUse (questions only) fires once the user has answered in the terminal, and turns
17
+ // the Slack message into what they chose, so it doesn't sit there asking.
18
+ //
19
+ // All run in the background (`async`), so a slow Slack never holds up the prompt. This file is the
20
+ // pure part — what to say, and how to edit settings.json — plus the file plumbing for installing.
21
+ // OpenCode and Codex have their own adapters (hooks-opencode.mjs, hooks-codex.mjs) that turn their
22
+ // events into the same `permission` steps, so everything past reading the payload is shared.
23
+
24
+ import fs from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+
28
+ /** A permission request older than this is not the one on screen any more. */
29
+ export const REQUEST_TTL_MS = 10 * 60_000;
30
+ /** How many recent requests a session remembers; parallel tool calls can queue several. */
31
+ const KEEP_REQUESTS = 5;
32
+ const MAX_DETAIL = 500;
33
+
34
+ const str = (v) => (typeof v === "string" ? v.trim() : "");
35
+
36
+ // ---------------------------------------------------------------- describing a request
37
+
38
+ /** A path relative to the project when it is inside it: shorter to read on a phone. */
39
+ function shortPath(file, cwd) {
40
+ const f = str(file);
41
+ if (!f || !cwd) return f;
42
+ const rel = path.relative(cwd, f);
43
+ return rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.split(path.sep).join("/") : f;
44
+ }
45
+
46
+ /** `mcp__github__create_issue` → { server: "github", tool: "create_issue" }. */
47
+ function mcpParts(name) {
48
+ const m = /^mcp__(.+?)__(.+)$/.exec(name);
49
+ return m ? { server: m[1], tool: m[2] } : null;
50
+ }
51
+
52
+ function compactJson(input) {
53
+ if (input === undefined || input === null) return "";
54
+ try {
55
+ const json = JSON.stringify(input);
56
+ return json === "{}" ? "" : json;
57
+ } catch {
58
+ return "";
59
+ }
60
+ }
61
+
62
+ /**
63
+ * What a tool call is, in words: `action` finishes "I need your permission to …", `detail` is the
64
+ * thing itself (a command, a path, a URL), `why` is the agent's own description when it gave one.
65
+ */
66
+ export function describeTool(toolName, input = {}, cwd = "") {
67
+ const tool = str(toolName);
68
+ const i = input && typeof input === "object" ? input : {};
69
+ const mcp = mcpParts(tool);
70
+ if (mcp) return { action: `use the ${mcp.server} tool ${mcp.tool.replace(/_/g, " ")}`, detail: compactJson(i), why: "" };
71
+ switch (tool) {
72
+ case "Bash":
73
+ case "PowerShell":
74
+ return { action: "run a command", detail: str(i.command), why: str(i.description) };
75
+ case "Edit":
76
+ case "MultiEdit":
77
+ return { action: "edit a file", detail: shortPath(i.file_path, cwd), why: "" };
78
+ case "Write":
79
+ return { action: "write a file", detail: shortPath(i.file_path, cwd), why: "" };
80
+ case "NotebookEdit":
81
+ return { action: "edit a notebook", detail: shortPath(i.notebook_path, cwd), why: "" };
82
+ case "Read":
83
+ return { action: "read a file", detail: shortPath(i.file_path, cwd), why: "" };
84
+ case "Glob":
85
+ case "Grep":
86
+ return { action: "search files", detail: [str(i.pattern), shortPath(i.path, cwd)].filter(Boolean).join(" in "), why: "" };
87
+ case "WebFetch":
88
+ return { action: "fetch a web page", detail: str(i.url), why: "" };
89
+ case "WebSearch":
90
+ return { action: "search the web", detail: str(i.query), why: "" };
91
+ case "Agent":
92
+ case "Task":
93
+ return { action: "start a subagent", detail: str(i.description) || str(i.prompt), why: "" };
94
+ // OpenCode's own permission names, for the ones that aren't a tool.
95
+ case "external_directory":
96
+ return { action: "work outside the project", detail: (i.patterns ?? []).join("\n"), why: "" };
97
+ case "doom_loop":
98
+ return { action: "keep going after repeating itself", detail: "", why: "" };
99
+ case "":
100
+ return { action: "continue", detail: "", why: "" };
101
+ default:
102
+ return { action: `use ${tool}`, detail: compactJson(i), why: "" };
103
+ }
104
+ }
105
+
106
+ /** Something safe to put inside a Slack code block. */
107
+ function codeBlock(text) {
108
+ const clipped = text.length > MAX_DETAIL ? `${text.slice(0, MAX_DETAIL)}…` : text;
109
+ return "```" + clipped.replace(/```/g, "'''") + "```";
110
+ }
111
+
112
+ /**
113
+ * Tools that are the agent asking the user something, rather than asking leave to act. Claude Code
114
+ * shows them as prompts too, so the same hooks see them; they are the ones worth a ping by default.
115
+ */
116
+ export const QUESTION_TOOLS = ["AskUserQuestion", "ExitPlanMode"];
117
+ export const isQuestion = (tool) => QUESTION_TOOLS.includes(tool);
118
+
119
+ const clip = (text, max) => (text.length > max ? `${text.slice(0, max).trimEnd()}…` : text);
120
+ const quote = (text) => text.split("\n").map((l) => `>${l}`).join("\n");
121
+
122
+ /** AskUserQuestion's questions, tidied: [{ question, options: [label], multi }]. */
123
+ export function questionsOf(input) {
124
+ const list = Array.isArray(input?.questions) ? input.questions : [];
125
+ return list
126
+ .map((q) => ({
127
+ question: str(q?.question),
128
+ options: (Array.isArray(q?.options) ? q.options : []).map((o) => str(typeof o === "string" ? o : o?.label)).filter(Boolean),
129
+ multi: Boolean(q?.multiSelect),
130
+ }))
131
+ .filter((q) => q.question);
132
+ }
133
+
134
+ /** A sentence that is fine to say out loud: tags and markdown out, one line, not too long. */
135
+ function speakable(text, max = 200) {
136
+ return clip(str(text).replace(/[`*_>#]/g, "").replace(/\s+/g, " "), max);
137
+ }
138
+
139
+ /**
140
+ * The notification for a prompt: `kind`, `message` for Slack, `say` for speech. `request` is what
141
+ * the PermissionRequest hook noted, when it did; without it (a sandboxed network request, say, which
142
+ * Claude Code asks about without that hook) Claude Code's own notification text is used.
143
+ */
144
+ export function permissionNotice({ request = null, notice = "", cwd = "" } = {}) {
145
+ if (request?.tool === "AskUserQuestion") {
146
+ const questions = questionsOf(request.input);
147
+ const blocks = questions.map((q) => quote([`*${q.question}*`, ...q.options.map((o) => `• ${o}`)].join("\n")));
148
+ const heading = questions.length > 1 ? `*Has ${questions.length} questions for you*` : "*Has a question for you*";
149
+ return {
150
+ kind: "input",
151
+ message: [heading, ...blocks, "_Answer in the terminal._"].join("\n"),
152
+ say: questions.length
153
+ ? `[neutral] I have a question for you. ${speakable(questions[0].question)}${questions.length > 1 ? ` And ${questions.length - 1} more.` : ""}`
154
+ : "[neutral] I have a question for you. It's in the terminal.",
155
+ };
156
+ }
157
+ if (request?.tool === "ExitPlanMode") {
158
+ const plan = str(request.input?.plan);
159
+ return {
160
+ kind: "input",
161
+ message: ["*Has a plan ready for you to approve*", ...(plan ? [quote(clip(plan, 1200))] : []), "_Approve it in the terminal._"].join("\n"),
162
+ say: "[neutral] My plan is ready. It needs your approval in the terminal.",
163
+ };
164
+ }
165
+ if (!request) {
166
+ const text = str(notice) || "Claude needs your permission to continue.";
167
+ return {
168
+ kind: "blocked",
169
+ message: `*Waiting for your permission.* ${text}\n_Answer it in the terminal._`,
170
+ say: "[serious] I need your permission to continue. It's waiting in the terminal.",
171
+ };
172
+ }
173
+ const { action, detail, why } = describeTool(request.tool, request.input, cwd);
174
+ const lines = [`*Waiting for your permission* to ${action}${why ? `: ${why}` : "."}`];
175
+ if (detail) lines.push(codeBlock(detail));
176
+ lines.push("_Answer it in the terminal._");
177
+ return {
178
+ kind: "blocked",
179
+ message: lines.join("\n"),
180
+ say: `[serious] I need your permission to ${action}. It's waiting in the terminal.`,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * The answers to an AskUserQuestion, from whatever PostToolUse carried. Claude Code documents an
186
+ * `answers` map (question text → label, several joined with commas) on the tool's input; the
187
+ * response is checked too, since that is where a tool's result normally is.
188
+ */
189
+ export function answersOf(input, response) {
190
+ const joined = (a) => (Array.isArray(a) ? a.map(String).join(", ") : str(String(a ?? "")));
191
+ // OpenCode answers in order, one list of labels per question.
192
+ if (Array.isArray(response?.answers)) {
193
+ const questions = questionsOf(input);
194
+ return Object.fromEntries(response.answers.map((a, i) => [questions[i]?.question ?? "", joined(a)]).filter(([q]) => q));
195
+ }
196
+ for (const source of [response?.answers, input?.answers]) {
197
+ if (source && typeof source === "object" && !Array.isArray(source)) {
198
+ const entries = Object.entries(source).map(([q, a]) => [str(q), joined(a)]);
199
+ if (entries.length) return Object.fromEntries(entries);
200
+ }
201
+ }
202
+ return {};
203
+ }
204
+
205
+ /**
206
+ * What the Slack message turns into once the user has dealt with it in the terminal. `outcome` is
207
+ * how: answered (the default), dismissed, or — for leave to act — allowed or denied.
208
+ */
209
+ export function answeredNotice(request, response, outcome = "answered") {
210
+ if (outcome === "dismissed") return { message: "*Dismissed in the terminal.*" };
211
+ if (outcome === "allowed") return { message: "*Allowed in the terminal.*" };
212
+ if (outcome === "denied") return { message: "*Denied in the terminal.*" };
213
+ if (request?.tool === "ExitPlanMode") return { message: "*Plan approved in the terminal.*" };
214
+ const answers = answersOf(request?.input, response);
215
+ const lines = questionsOf(request?.input).map((q) => quote(`${q.question}\n*${answers[q.question] || "answered"}*`));
216
+ return { message: ["*Answered in the terminal.*", ...lines].join("\n") };
217
+ }
218
+
219
+ /** Remember a request, dropping stale ones and keeping only the last few. */
220
+ export function rememberRequest(requests = [], request, now = Date.now()) {
221
+ const fresh = (requests ?? []).filter((r) => now - Date.parse(r.at) < REQUEST_TTL_MS);
222
+ return [...fresh, request].slice(-KEEP_REQUESTS);
223
+ }
224
+
225
+ /**
226
+ * Which remembered request a prompt notification is about. Claude Code's text names the tool
227
+ * ("Claude needs your permission to use Bash"), so prefer the newest un-pinged request for that
228
+ * tool; otherwise the newest un-pinged one. Null when there's nothing fitting — then the ping is
229
+ * still sent, with Claude Code's own words.
230
+ */
231
+ export function matchRequest(requests = [], notice = "", now = Date.now()) {
232
+ const open = (requests ?? []).filter((r) => !r.pingedAt && !r.answeredAt && now - Date.parse(r.at) < REQUEST_TTL_MS);
233
+ if (!open.length) return null;
234
+ const text = str(notice).toLowerCase();
235
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
236
+ const named = open.filter((r) => r.tool && new RegExp(`\\buse ${escape(r.tool.toLowerCase())}(?![\\w])`).test(text));
237
+ return (named.length ? named : open).at(-1);
238
+ }
239
+
240
+ // ---------------------------------------------------------------- reading what Claude Code sends
241
+
242
+ /**
243
+ * Turn a Claude Code hook payload into a `permission` command, or null for anything we don't act on.
244
+ * The payload is whatever arrived on stdin, so nothing about its shape is taken on trust.
245
+ */
246
+ export function claudeHookRequest(payload) {
247
+ if (!payload || typeof payload !== "object") return null;
248
+ const sessionId = str(payload.session_id);
249
+ if (!sessionId) return null;
250
+ const cwd = str(payload.cwd);
251
+ const base = { sessionId, cwd };
252
+ if (payload.hook_event_name === "PermissionRequest") {
253
+ const tool = str(payload.tool_name);
254
+ if (!tool) return null;
255
+ const input = payload.tool_input && typeof payload.tool_input === "object" ? payload.tool_input : {};
256
+ return { ...base, args: { event: "request", tool, input, cwd } };
257
+ }
258
+ if (payload.hook_event_name === "Notification" && payload.notification_type === "permission_prompt") {
259
+ return { ...base, args: { event: "prompt", notice: str(payload.message), cwd } };
260
+ }
261
+ if (payload.hook_event_name === "PostToolUse" && isQuestion(str(payload.tool_name))) {
262
+ const input = payload.tool_input && typeof payload.tool_input === "object" ? payload.tool_input : {};
263
+ const response = payload.tool_response && typeof payload.tool_response === "object" ? payload.tool_response : {};
264
+ return { ...base, args: { event: "answered", tool: str(payload.tool_name), input, response, cwd } };
265
+ }
266
+ return null;
267
+ }
268
+
269
+ /** Read the hook's JSON from stdin. Gives up quietly: a hook must never hang the agent. */
270
+ export function readStdin(stream = process.stdin, timeoutMs = 3_000) {
271
+ return new Promise((resolve) => {
272
+ if (stream.isTTY) return resolve(null);
273
+ let raw = "";
274
+ const finish = () => {
275
+ clearTimeout(timer);
276
+ try {
277
+ resolve(raw.trim() ? JSON.parse(raw) : null);
278
+ } catch {
279
+ resolve(null);
280
+ }
281
+ };
282
+ const timer = setTimeout(finish, timeoutMs);
283
+ stream.setEncoding("utf8");
284
+ stream.on("data", (chunk) => (raw += chunk));
285
+ stream.on("end", finish);
286
+ stream.on("error", finish);
287
+ });
288
+ }
289
+
290
+ // ---------------------------------------------------------------- installing into Claude Code
291
+
292
+ /** ~/.claude/settings.json, or wherever CLAUDE_CONFIG_DIR puts it. */
293
+ export function claudeSettingsPath(env = process.env) {
294
+ const dir = str(env.CLAUDE_CONFIG_DIR) || path.join(os.homedir(), ".claude");
295
+ return path.join(dir, "settings.json");
296
+ }
297
+
298
+ /** The hook command: node, this script, `hook claude`. Exec form, so no shell quoting on any OS. */
299
+ export function claudeHookCommand(script) {
300
+ return { type: "command", command: "node", args: [script.split(path.sep).join("/"), "hook", "claude"], async: true };
301
+ }
302
+
303
+ /** One of ours: any copy of roger-roger.mjs run with `hook`, wherever the skill was installed. */
304
+ export function isOurHook(hook) {
305
+ const args = Array.isArray(hook?.args) ? hook.args.map(String) : [];
306
+ const i = args.findIndex((a) => /(^|[\\/])roger-roger\.mjs$/.test(a));
307
+ if (i !== -1 && args[i + 1] === "hook") return true;
308
+ // A shell-form entry someone wrote by hand, same idea.
309
+ return typeof hook?.command === "string" && /roger-roger\.mjs["']?\s+hook\b/.test(hook.command);
310
+ }
311
+
312
+ // PermissionRequest notes every prompt, Notification pings about one, PostToolUse closes it once the
313
+ // user has answered in the terminal — only for questions, so it doesn't run after every tool call.
314
+ const OUR_EVENTS = { PermissionRequest: null, Notification: "permission_prompt", PostToolUse: QUESTION_TOOLS.join("|") };
315
+
316
+ /** Settings with every roger-roger hook taken out, and empty groups and events tidied away. */
317
+ export function withoutOurHooks(settings) {
318
+ const next = structuredClone(settings ?? {});
319
+ if (!next.hooks || typeof next.hooks !== "object") return next;
320
+ for (const [event, groups] of Object.entries(next.hooks)) {
321
+ if (!Array.isArray(groups)) continue;
322
+ const kept = groups
323
+ .map((g) => (Array.isArray(g?.hooks) ? { ...g, hooks: g.hooks.filter((h) => !isOurHook(h)) } : g))
324
+ .filter((g) => !Array.isArray(g?.hooks) || g.hooks.length);
325
+ if (kept.length) next.hooks[event] = kept;
326
+ else delete next.hooks[event];
327
+ }
328
+ if (!Object.keys(next.hooks).length) delete next.hooks;
329
+ return next;
330
+ }
331
+
332
+ /**
333
+ * Settings with our hooks in, replacing any older copy of them and leaving everything else be.
334
+ * Claude Code's by default; Codex's hooks.json has the same layout, with its own events and handler.
335
+ */
336
+ export function withOurHooks(settings, script, { events = OUR_EVENTS, handler = claudeHookCommand } = {}) {
337
+ const next = withoutOurHooks(settings);
338
+ next.hooks ??= {};
339
+ for (const [event, matcher] of Object.entries(events)) {
340
+ const group = { ...(matcher ? { matcher } : {}), hooks: [handler(script)] };
341
+ next.hooks[event] = [...(Array.isArray(next.hooks[event]) ? next.hooks[event] : []), group];
342
+ }
343
+ return next;
344
+ }
345
+
346
+ /** The script a hook of ours runs: its first argument, or the quoted path in a shell command. */
347
+ function scriptOf(hook) {
348
+ if (Array.isArray(hook?.args) && hook.args[0]) return String(hook.args[0]);
349
+ const quoted = /(['"])((?:(?!\1).)*roger-roger\.mjs)\1/.exec(String(hook?.command ?? ""));
350
+ return quoted ? quoted[2].replace(/''/g, "'") : hook?.command ?? null;
351
+ }
352
+
353
+ /** What is installed: which events have a hook of ours, and which script each one runs. */
354
+ export function ourHooks(settings) {
355
+ const found = {};
356
+ for (const [event, groups] of Object.entries(settings?.hooks ?? {})) {
357
+ if (!Array.isArray(groups)) continue;
358
+ for (const g of groups) {
359
+ for (const h of Array.isArray(g?.hooks) ? g.hooks : []) {
360
+ if (isOurHook(h)) found[event] = { matcher: g.matcher ?? null, script: scriptOf(h) };
361
+ }
362
+ }
363
+ }
364
+ return found;
365
+ }
366
+
367
+ export function readSettings(file) {
368
+ if (!fs.existsSync(file)) return {};
369
+ const raw = fs.readFileSync(file, "utf8");
370
+ if (!raw.trim()) return {};
371
+ try {
372
+ return JSON.parse(raw);
373
+ } catch (e) {
374
+ // Never overwrite a file we can't read: it is the user's, and it holds far more than hooks.
375
+ throw new Error(`${file} is not valid JSON (${e.message}); fix it, then run this again`);
376
+ }
377
+ }
378
+
379
+ export function writeSettings(file, settings) {
380
+ fs.mkdirSync(path.dirname(file), { recursive: true });
381
+ if (fs.existsSync(file)) fs.copyFileSync(file, `${file}.roger-roger.bak`);
382
+ const tmp = `${file}.${process.pid}.tmp`;
383
+ fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n", "utf8");
384
+ fs.renameSync(tmp, file);
385
+ }
386
+
387
+ export function claudeStatus({ env = process.env, script } = {}) {
388
+ const file = claudeSettingsPath(env);
389
+ const found = ourHooks(readSettings(file));
390
+ const events = Object.keys(OUR_EVENTS);
391
+ const installed = events.every((e) => found[e]);
392
+ const scripts = [...new Set(Object.values(found).map((f) => f.script))];
393
+ return {
394
+ agent: "claude",
395
+ settings: file,
396
+ installed,
397
+ partial: !installed && events.some((e) => found[e]),
398
+ hooks: found,
399
+ ...(scripts.length ? { script: scripts[0], scriptExists: scripts.every((s) => s && fs.existsSync(s)) } : {}),
400
+ ...(script && scripts.length && scripts.some((s) => path.resolve(s) !== path.resolve(script)) ? { note: "installed from a different copy of the skill; `hooks install` points them at this one" } : {}),
401
+ };
402
+ }
403
+
404
+ export function installClaude({ env = process.env, script }) {
405
+ const file = claudeSettingsPath(env);
406
+ const before = readSettings(file);
407
+ const after = withOurHooks(before, script);
408
+ const changed = JSON.stringify(before) !== JSON.stringify(after);
409
+ if (changed) writeSettings(file, after);
410
+ return { ...claudeStatus({ env, script }), changed, ...(changed && fs.existsSync(`${file}.roger-roger.bak`) ? { backup: `${file}.roger-roger.bak` } : {}) };
411
+ }
412
+
413
+ export function uninstallClaude({ env = process.env } = {}) {
414
+ const file = claudeSettingsPath(env);
415
+ const before = readSettings(file);
416
+ const after = withoutOurHooks(before);
417
+ const changed = JSON.stringify(before) !== JSON.stringify(after);
418
+ if (changed) writeSettings(file, after);
419
+ return { ...claudeStatus({ env }), changed };
420
+ }