roger-roger 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -6,18 +6,17 @@ It works with any agent that supports [Agent Skills](https://skills.sh), such as
6
6
 
7
7
  ## Install
8
8
 
9
+ One command, in any shell on Windows, macOS or Linux:
10
+
9
11
  ```sh
10
- npm install -g roger-roger
11
- roger-roger install
12
+ npx roger-roger@latest install
12
13
  ```
13
14
 
14
- Or, without installing anything permanently:
15
+ It asks a few questions in the terminal and does the rest. First it offers to put the `roger-roger` command on your PATH (`npm install -g`), then it links the skill into every agent's skills folder it finds (Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Copilot, and `~/.agents/skills`), registers with [Herdr](https://herdr.dev) as a plugin when it's installed, connects Slack, lets you hear the sounds and the voices before you choose, and installs the terminal-question hooks and the tray icon. Once it's on your PATH, `roger-roger install` runs the same setup again whenever you want to change something.
15
16
 
16
- ```sh
17
- npx roger-roger install
18
- ```
17
+ If you'd rather install first and set up after, that works too: `npm install -g roger-roger`, then `roger-roger install`.
19
18
 
20
- `install` asks a few questions in the terminal and does the rest: it links the skill into every agent's skills folder it finds (Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Copilot, and `~/.agents/skills`), registers with [Herdr](https://herdr.dev) as a plugin when it's installed, connects Slack, lets you hear the sounds and the voices before you choose, and installs the terminal-question hooks and the tray icon. Run it again whenever you want to change something.
19
+ To take it off again, `npx roger-roger@latest uninstall` (or `roger-roger uninstall`): it lists what it finds, you tick what should go, and it removes the hooks, the skill links, the Herdr plugin, the daemon and the tray, your settings and tokens, and the global command. The Slack CLI is offered too, unticked. `roger-roger help` shows every command, and `roger-roger help notify` one command's flags.
21
20
 
22
21
  Other ways in:
23
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roger-roger",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Bring yourself back to the conversation: your coding agents reach you by Slack, a sound or a spoken line when they finish, get stuck, or need a decision, and you answer from your phone.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,10 +34,10 @@ Every command prints a single JSON object.
34
34
  Run `status` first. If `configured` is `false`, don't set things up in the conversation. Ask the user to run the setup in a terminal, and wait for them:
35
35
 
36
36
  ```
37
- roger-roger install (or, with nothing installed: npx roger-roger install)
37
+ npx roger-roger@latest install
38
38
  ```
39
39
 
40
- It asks everything in one sitting, with previews: how to be reached (Slack, sound, speech), connecting Slack (it creates the roger-roger Slack app; nobody copies tokens), which sound and which voice (space plays them), how much to say out loud, when to notify, the terminal-question hooks (`hooks`, under *Other commands*), and the tray. It also links the skill into every agent's skills folder and registers with Herdr when it's there. When the user says it's done, run `status` again and carry on.
40
+ That one line works in every shell (`roger-roger install` does the same once the command is on their PATH, which the setup offers first). It asks everything in one sitting, with previews: how to be reached (Slack, sound, speech), connecting Slack (it creates the roger-roger Slack app; nobody copies tokens), which sound and which voice (space plays them), how much to say out loud, when to notify, the terminal-question hooks (`hooks`, under *Other commands*), and the tray. It also links the skill into every agent's skills folder and registers with Herdr when it's there. When the user says it's done, run `status` again and carry on.
41
41
 
42
42
  Only if they can't get to a terminal: `setup` takes every setting as a flag (below), and `slack-setup` does the Slack steps one at a time (`slack-setup` prints `next`: `install-cli`, `login` — it returns a `/slackauthticket …` command for the user to paste into Slack and you pass back the code they get with `--challenge CODE --ticket TICKET` — `create`, then `done`, with the user's member ID as `userId` for `--slack-target`).
43
43
 
@@ -261,6 +261,8 @@ Judge it by what happens if you are wrong, not by whether it feels routine. A fo
261
261
  ## Other commands
262
262
 
263
263
  - `install`: the interactive setup, for the user to run in a terminal (section 1). `install --yes` asks nothing: it links the skill for every agent on the machine and registers with Herdr, and prints what it did.
264
+ - `uninstall`: takes it out again, for the user to run in a terminal (`npx roger-roger@latest uninstall`): hooks, skill links, the Herdr plugin, the daemon and the tray, settings and tokens, the global command. `--yes` removes all of that without asking (`--slack-cli` adds the Slack CLI). Never run it for the user unasked.
265
+ - `help [command]`: the commands, and one command's flags.
264
266
  - `status`: current config, whether the tokens are present, the daemon, running sessions, and every valid option value.
265
267
  - `slack-setup [status|install-cli|login|create]`: the Slack steps `install` runs, one at a time, for repairing a connection by hand. `create` again is safe: it updates the same app, which is what to do after the manifest changed or when a scope is missing.
266
268
  - `sessions [--all]`: which agents are running, what each is called, and what each is doing. `sessions rename sage plum` gives one a different name (the user's request, not yours to change on a whim).
@@ -6,7 +6,7 @@
6
6
 
7
7
  id = "roger-roger"
8
8
  name = "roger-roger"
9
- version = "0.1.0"
9
+ version = "0.1.2"
10
10
  min_herdr_version = "0.9.0"
11
11
  description = "Slack, a sound or a spoken line from the agents in your panes, and questions you answer from your phone"
12
12
  platforms = ["linux", "macos", "windows"]
@@ -0,0 +1,210 @@
1
+ // `roger-roger help`: the commands, grouped by what they are for, laid out to the terminal's width
2
+ // and coloured when it is one. `help <command>` shows that command's flags. The text lives here as
3
+ // data, so the overview, the per-command pages and the tests all read the same thing.
4
+
5
+ import { VERSION } from "./lib.mjs";
6
+ import { coloursOn, makeStyle, width } from "./tui.mjs";
7
+
8
+ export const TAGLINE = "Bring yourself back to the conversation: your agents reach you by Slack, a sound, or a voice.";
9
+
10
+ /** Every command: where it belongs, how it is called, what it does, and (for the busy ones) its flags. */
11
+ export const COMMANDS = [
12
+ // ---- setting up
13
+ { section: "Setting up", name: "install", usage: "[--yes]", summary: "Set everything up in the terminal: the command on your PATH, the skill for every agent, Herdr, Slack, the sound and the voice, hooks, tray. Run it again to change anything. --yes asks nothing and only links the skill.", flags: [["--yes", "no questions: link the skill for every agent on the machine and register with Herdr, then print what was done"]] },
14
+ { section: "Setting up", name: "uninstall", usage: "[--yes]", summary: "Take it out again: hooks, links, Herdr, the daemon and the tray, settings and tokens, the global command.", flags: [["--yes", "no questions: remove everything except the Slack CLI, then print what was done"]] },
15
+ { section: "Setting up", name: "status", usage: "", summary: "Settings, whether the tokens are there, the daemon, who is running, and every valid option value." },
16
+ {
17
+ section: "Setting up", name: "setup", usage: "[flags]", summary: "Change any setting; the rest are kept.",
18
+ flags: [
19
+ ["--methods slack,sound,speech", "how to be reached, any combination"],
20
+ ["--slack-target U0123|C0123|#name", "your member ID for DMs, or a channel"],
21
+ ["--slack-mention U0123", "who to mention in channel posts"],
22
+ ["--sound NAME", "chime, ding, marimba, bubble, alert"],
23
+ ["--voice NAME", "a voice of the speech provider (see voices)"],
24
+ ["--speech auto|brief|same", "what is spoken: the agent's choice, one sentence, or the whole message"],
25
+ ["--when auto|done-and-blocked|blocked-only|on-request", "when agents notify"],
26
+ ["--speech-provider gemini|openai|elevenlabs", "who makes the voice"],
27
+ ["--speech-key-var NAME|auto", "the environment variable holding that provider's key"],
28
+ ["--model ID", "the provider's speech model"],
29
+ ["--speech-volume 0-150", "the voice's volume"],
30
+ ["--duck on|off, --duck-level 0-100", "turn other sound down while the voice speaks (Windows)"],
31
+ ["--local-fallback true|false", "use the computer's voice when the provider fails"],
32
+ ["--say-who on|off", "start a spoken line with the agent's name"],
33
+ ["--session-label on|off", "the small name · machine · agent line under Slack messages"],
34
+ ["--remind 15m|off", "remind about an unanswered question after this long"],
35
+ ["--on-expire recommended|nothing", "what an unanswered question settles as"],
36
+ ["--quiet-hours 22:00-08:00|off, --quiet-mute speech,sound", "when, and what, to keep quiet"],
37
+ ["--permission-pings on|off", "also ping for ordinary permission prompts in the terminal"],
38
+ ["--terminal-wait 20s", "how long a terminal question waits before the ping (OpenCode, Codex)"],
39
+ ["--tray auto|off", "start the tray with the daemon once it is installed"],
40
+ ["--notes TEXT", "anything else the agent should follow"],
41
+ ],
42
+ },
43
+ { section: "Setting up", name: "slack-setup", usage: "[status|install-cli|login|create]", summary: "The Slack steps one at a time, for repairing a connection by hand. `next` says which comes next." },
44
+ { section: "Setting up", name: "hooks", usage: "status|install|uninstall [claude|opencode|codex|all]", summary: "Ping you when an agent asks a question in its own terminal and can't tell you itself." },
45
+ { section: "Setting up", name: "daemon", usage: "status|start|stop", summary: "The one process behind everything. It starts on demand; this is for looking at it, or restarting it after a change to the code." },
46
+ { section: "Setting up", name: "tray", usage: "status|start|stop|install", summary: "An icon showing every agent, a panel behind it, snooze on right-click. The native part is installed on request (about 4 MB)." },
47
+
48
+ // ---- reaching the user
49
+ {
50
+ section: "Reaching the user", name: "notify", usage: "--kind K --message TEXT [flags]", summary: "Post to Slack, play the sound, speak the line: any or all, as configured.",
51
+ flags: [
52
+ ["--kind done|blocked|input|milestone|error|info", "sets the Slack emoji"],
53
+ ["--message TEXT", "the written message, self-contained (Slack mrkdwn works)"],
54
+ ["--say TEXT", "the spoken line; short, no paths or URLs. Gemini tone tags like [positive] work"],
55
+ ["--only sound,speech", "this call only, some of the methods"],
56
+ ["--project NAME", "the label shown in Slack (default: the directory name)"],
57
+ ["--attach FILE", "a file to post with the message; repeatable"],
58
+ ["--summary, --field \"Label=value\"", "an end-of-task card with key facts, how long it took, and every decision"],
59
+ ["--actions \"A|B\", --recommend A", "follow-up buttons; `wait` the returned id like a question"],
60
+ ["--reply-to TS", "post in a thread: the replyTo that came with the user's message"],
61
+ ["--then-listen [25m]", "post, then stay reachable until the user says something"],
62
+ ],
63
+ example: "roger-roger notify --kind done --message \"Tests green, PR ready.\" --say \"[positive] All green.\" --session \"billing refactor\" --agent-model \"opus 5\"",
64
+ },
65
+ { section: "Reaching the user", name: "progress", usage: "--message TEXT [--key NAME] [--done]", summary: "One Slack message, edited in place as the work moves on, with Pause, Stop and Note buttons." },
66
+ { section: "Reaching the user", name: "control", usage: "[--key NAME] [--wait DURATION]", summary: "Read Pause, Stop and new messages without posting. --wait blocks while paused." },
67
+ { section: "Reaching the user", name: "listen", usage: "[--wait 25m]", summary: "Park until the user says something. Run it in the background as the last thing you do." },
68
+ { section: "Reaching the user", name: "snooze", usage: "[1h|off]", summary: "Sound and speech off for a while; Slack keeps working." },
69
+
70
+ // ---- asking
71
+ {
72
+ section: "Asking the user", name: "ask", usage: "--question TEXT --choices \"A|B\" [flags]", summary: "A question with a button per option in Slack. Returns an id at once; `wait` for the answer.",
73
+ flags: [
74
+ ["--question TEXT", "self-contained"],
75
+ ["--choices \"A|B\"", "2 to 10 options"],
76
+ ["--recommend A", "the option you'd pick, shown in green; with --multi, several"],
77
+ ["--multi", "checkboxes and a Submit button instead of one button per option"],
78
+ ["--details TEXT", "context under the question"],
79
+ ["--draft TEXT | --draft-file PATH", "text to approve or edit before use: a commit message, a PR description"],
80
+ ["--expires 45m", "how long the question stays open"],
81
+ ["--on-expire recommended|nothing", "settle as the recommended option, or just expire. Use nothing for anything hard to undo"],
82
+ ["--remind 15m|off", "reminders for this question"],
83
+ ["--other true|false", "the Other… button for a typed answer"],
84
+ ["--reply-to TS", "ask in a thread"],
85
+ ["--wait [DURATION]", "block until settled, as `wait` would"],
86
+ ["--say, --only, --kind, --attach", "as for notify"],
87
+ ],
88
+ example: "roger-roger ask --question \"Deploy now?\" --choices \"Yes|Tomorrow|No\" --recommend Yes --on-expire nothing --wait 10m",
89
+ },
90
+ { section: "Asking the user", name: "wait", usage: "ID [--timeout DURATION]", summary: "Block until answered, expired or cancelled. Exit 0 answered, 5 expired or cancelled, 4 still pending after --timeout, 6 the user sent a message instead." },
91
+ { section: "Asking the user", name: "check", usage: "ID", summary: "The question's state, without waiting." },
92
+ { section: "Asking the user", name: "cancel", usage: "ID [--reason TEXT]", summary: "Withdraw a question; its Slack message stops offering buttons." },
93
+ { section: "Asking the user", name: "decisions", usage: "[--all]", summary: "Pending questions; --all includes recent closed ones." },
94
+
95
+ // ---- sessions
96
+ { section: "Sessions", name: "sessions", usage: "[--all] | rename NAME NEWNAME", summary: "Who is running and what each is called. Rename gives one a different colour name." },
97
+ { section: "Sessions", name: "end", usage: "[NAME]", summary: "This session is finished; stop routing messages to it." },
98
+ { section: "Sessions", name: "inbox", usage: "", summary: "Messages the user sent this session, and anything left for the next agent." },
99
+
100
+ // ---- sounds and voices
101
+ { section: "Sounds and voices", name: "play", usage: "[SOUND]", summary: "Hear a sound (default: the configured one)." },
102
+ { section: "Sounds and voices", name: "say", usage: "TEXT [--voice NAME] [--local | --test]", summary: "Hear a voice. --test insists on the provider: its voice or an error, never the computer's." },
103
+ { section: "Sounds and voices", name: "voices", usage: "[--refresh]", summary: "The provider's voices and models; --refresh asks the provider." },
104
+ { section: "Sounds and voices", name: "preview-sounds", usage: "[SOUND...] [--voice NAME] [--local]", summary: "Each sound announced by name, then played." },
105
+ { section: "Sounds and voices", name: "preview-voices", usage: "[VOICE...] [--all] [--text TEXT]", summary: "Each voice introduces itself." },
106
+ ];
107
+
108
+ export const ANY_COMMAND = [
109
+ ["--session NAME", "what this work is called, and how the session is recognised between commands: always pass it"],
110
+ ["--agent-model MODEL", "your model, short: \"opus 5\", \"gpt-6\""],
111
+ ["--agent NAME", "your agent product, when it can't be detected"],
112
+ ["--session-id ID, --agent-pid PID", "when the agent knows better than the environment does"],
113
+ ];
114
+
115
+ /** Text folded to `max` columns at spaces; a word longer than that stands alone. */
116
+ export function wrap(text, max) {
117
+ const lines = [];
118
+ let line = "";
119
+ for (const word of String(text).split(/\s+/).filter(Boolean)) {
120
+ if (line && width(line) + 1 + width(word) > max) {
121
+ lines.push(line);
122
+ line = word;
123
+ } else line = line ? `${line} ${word}` : word;
124
+ }
125
+ if (line) lines.push(line);
126
+ return lines.length ? lines : [""];
127
+ }
128
+
129
+ /** A two-column table: the left column padded to one width, the right wrapped beside it. */
130
+ function table(rows, { indent, columns, c, left = (s) => s, right = (s) => s, gap = 3, maxLeft = 34 }) {
131
+ const leftWidth = Math.min(maxLeft, Math.max(...rows.map(([l]) => width(l))));
132
+ const rightWidth = Math.max(20, columns - indent - leftWidth - gap);
133
+ const out = [];
134
+ for (const [l, r] of rows) {
135
+ const lines = wrap(r, rightWidth);
136
+ if (width(l) > leftWidth) {
137
+ out.push(" ".repeat(indent) + left(l));
138
+ for (const line of lines) out.push(" ".repeat(indent + leftWidth + gap) + right(line));
139
+ continue;
140
+ }
141
+ const pad = " ".repeat(leftWidth - width(l) + gap);
142
+ out.push(" ".repeat(indent) + left(l) + pad + right(lines[0]));
143
+ for (const line of lines.slice(1)) out.push(" ".repeat(indent + leftWidth + gap) + right(line));
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /** The overview, or one command's page. Returns the text; the caller prints it. */
149
+ export function helpText(command = "", { columns = process.stdout.columns || 100, colours = coloursOn() } = {}) {
150
+ const c = makeStyle(colours);
151
+ const cols = Math.max(60, Math.min(columns, 110));
152
+ const out = [""];
153
+ const title = `${c.bold("roger-roger")}${VERSION ? " " + c.dim(VERSION) : ""}`;
154
+
155
+ if (command) {
156
+ const cmd = COMMANDS.find((x) => x.name === command);
157
+ if (!cmd) return null;
158
+ out.push(` ${title}`);
159
+ out.push("");
160
+ out.push(` ${c.cyan(c.bold(cmd.name))}${cmd.usage ? " " + c.dim(cmd.usage) : ""}`);
161
+ for (const line of wrap(cmd.summary, cols - 2)) out.push(` ${line}`);
162
+ if (cmd.flags?.length) {
163
+ out.push("");
164
+ out.push(` ${c.bold("Flags")}`);
165
+ out.push(...table(cmd.flags, { indent: 4, columns: cols, c, left: c.cyan }));
166
+ }
167
+ if (cmd.example) {
168
+ out.push("");
169
+ out.push(` ${c.bold("Example")}`);
170
+ for (const line of wrap(cmd.example, cols - 4)) out.push(` ${c.dim(line)}`);
171
+ }
172
+ out.push("");
173
+ out.push(` ${c.bold("Any command")}`);
174
+ out.push(...table(ANY_COMMAND, { indent: 4, columns: cols, c, left: c.cyan }));
175
+ out.push("");
176
+ return out.join("\n") + "\n";
177
+ }
178
+
179
+ out.push(` ${title}`);
180
+ for (const line of wrap(TAGLINE, cols - 2)) out.push(` ${c.dim(line)}`);
181
+ out.push("");
182
+ out.push(` ${c.bold("Usage")}`);
183
+ out.push(...table([["roger-roger <command> [flags]", "every command prints one JSON object"], ["roger-roger help <command>", "that command's flags"]], {
184
+ indent: 4, columns: cols, c, maxLeft: 30, left: (l) => c.cyan("roger-roger") + " " + c.dim(l.slice("roger-roger ".length)),
185
+ }));
186
+ const sections = [...new Set(COMMANDS.map((x) => x.section))];
187
+ for (const section of sections) {
188
+ out.push("");
189
+ out.push(` ${c.bold(section)}`);
190
+ const rows = COMMANDS.filter((x) => x.section === section).map((x) => [x.usage ? `${x.name} ${x.usage}` : x.name, x.summary]);
191
+ const styled = rows.map(([l, r]) => [l, r]);
192
+ out.push(...table(styled, {
193
+ indent: 4, columns: cols, c, maxLeft: 30,
194
+ left: (l) => {
195
+ const [name, ...rest] = l.split(" ");
196
+ return c.cyan(name) + (rest.length ? " " + c.dim(rest.join(" ")) : "");
197
+ },
198
+ }));
199
+ }
200
+ out.push("");
201
+ out.push(` ${c.bold("Any command")}`);
202
+ out.push(...table(ANY_COMMAND, { indent: 4, columns: cols, c, left: c.cyan, maxLeft: 30 }));
203
+ out.push("");
204
+ out.push(` ${c.bold("Start here")}`);
205
+ out.push(...table([["npx roger-roger@latest install", "with nothing installed yet"], ["roger-roger install", "once the command is on your PATH; the same setup, to change anything"]], {
206
+ indent: 4, columns: cols, c, maxLeft: 30, left: c.cyan, right: c.dim,
207
+ }));
208
+ out.push("");
209
+ return out.join("\n") + "\n";
210
+ }
@@ -11,23 +11,15 @@ import fs from "node:fs";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
  import { spawnSync } from "node:child_process";
14
- import { SKILL_DIR, VOICES, applySetup, configPath, listSounds, loadConfig, saveConfig, voiceSample } from "./lib.mjs";
15
- import { PROVIDERS, SPEECH_SETUP, catalog, keyFor } from "./tts.mjs";
16
- import { saveKey } from "./speechkey.mjs";
14
+ import { SKILL_DIR, VERSION, VOICES, applySetup, configPath, listSounds, loadConfig, rogerRogerHome, saveConfig, voiceSample } from "./lib.mjs";
15
+ import { PROVIDERS, SPEECH_SETUP, catalog } from "./tts.mjs";
16
+ import { envValue, hasSavedKey, saveKey } from "./speechkey.mjs";
17
17
  import { createApp, loginFinish, loginStart, setupStatus } from "./slackapp.mjs";
18
- import { installCli } from "./slackcli.mjs";
19
- import { call } from "./client.mjs";
18
+ import { cliInstallDir, findCli, installCli } from "./slackcli.mjs";
19
+ import { call, connect, request } from "./client.mjs";
20
20
  import { identity } from "./agent.mjs";
21
21
  import { Cancelled, createUI } from "./tui.mjs";
22
22
 
23
- export const VERSION = (() => {
24
- try {
25
- return JSON.parse(fs.readFileSync(path.join(SKILL_DIR, "..", "..", "package.json"), "utf8")).version ?? "";
26
- } catch {
27
- return "";
28
- }
29
- })();
30
-
31
23
  // ---------------------------------------------------------------- where skills live
32
24
 
33
25
  /** The agents that read skills from a folder in the home directory, and where. */
@@ -103,6 +95,52 @@ export function applyLink(link, target, plan, fsx = fs, now = new Date()) {
103
95
  return { ...plan, done: true };
104
96
  }
105
97
 
98
+ // ---------------------------------------------------------------- the global copy
99
+
100
+ /**
101
+ * Run npm with fixed arguments (never anything typed). On Windows npm is a .cmd, which Node only
102
+ * runs through a shell, and a shell wants one command line rather than an argument list.
103
+ */
104
+ function npmSync(argv, env, timeout) {
105
+ const options = { encoding: "utf8", windowsHide: true, timeout, env };
106
+ return process.platform === "win32"
107
+ ? spawnSync(["npm", ...argv].join(" "), { ...options, shell: true })
108
+ : spawnSync("npm", argv, options);
109
+ }
110
+
111
+ /** Where npm keeps global packages, and whether roger-roger is among them. Null when npm isn't around. */
112
+ export function globalCopy(env = process.env) {
113
+ try {
114
+ const r = npmSync(["root", "-g"], env, 20_000);
115
+ const root = (r.stdout ?? "").trim();
116
+ if (r.status !== 0 || !root) return null;
117
+ const pkg = path.join(root, "roger-roger");
118
+ return { root, dir: path.join(pkg, "skills", "roger-roger"), present: fs.existsSync(path.join(pkg, "package.json")) };
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Which copy of the skill everything should point at, and whether to offer a global install:
126
+ * the copy this runs from, unless that is a passing one (npx's cache, say) and there is no global
127
+ * copy yet. A global copy that exists beside a checkout or a skills.sh copy is left alone: whoever
128
+ * runs this copy meant this copy.
129
+ */
130
+ export function pickTarget(running, global) {
131
+ if (!global) return { target: running, offer: false, reason: "npm was not found" };
132
+ if (norm(running) === norm(global.dir)) return { target: running, offer: false, reason: "global" };
133
+ if (global.present) return { target: running, offer: false, reason: "a global copy exists, but this one was run" };
134
+ return { target: running, offer: true, reason: "no global copy yet" };
135
+ }
136
+
137
+ /** `npm install -g roger-roger@latest`. Throws with npm's last words when it fails. */
138
+ export function installGlobally(env = process.env) {
139
+ const r = npmSync(["install", "-g", "roger-roger@latest", "--no-audit", "--no-fund", "--loglevel", "error"], env, 300_000);
140
+ if (r.status !== 0) throw new Error((r.stderr || r.stdout || r.error?.message || `npm exited with ${r.status}`).trim().split(/\r?\n/).slice(-3).join(" "));
141
+ return r.stdout.trim();
142
+ }
143
+
106
144
  // ---------------------------------------------------------------- herdr
107
145
 
108
146
  /** The `herdr` binary, from the pane's environment or PATH: `{ command, version }`, or null. */
@@ -272,24 +310,43 @@ async function chooseSpeech(ui, { config, persist }) {
272
310
  const p = PROVIDERS[provider];
273
311
  // Saved now so the previews below speak with this provider, not the one configured before.
274
312
  await persist({ "speech-provider": provider });
275
- let key = keyFor({ speechProvider: provider, speechKeyVar: config?.speechProvider === provider ? config?.speechKeyVar ?? "" : "" });
276
- if (key.value) {
277
- ui.done(`${p.label} key found`, `in ${key.name}`);
278
- } else {
279
- const pasted = await ui.text({
280
- message: `${p.label} API key`,
281
- mask: true,
282
- placeholder: "paste it, or leave empty to use the computer's own voice for now",
283
- });
313
+
314
+ // Where the key is: the user says, rather than the setup assuming from what happens to be set.
315
+ const keyFile = path.join(rogerRogerHome(), "keys.json");
316
+ const chosenBefore = config?.speechProvider === provider ? String(config?.speechKeyVar ?? "") : "";
317
+ const describe = (name) => (process.env[name] ? "set in your environment" : hasSavedKey(name) ? "a key pasted into an earlier setup" : envValue(name) ? "set in your saved variables" : "not set");
318
+ const known = [...new Set([...p.keyVars, ...(chosenBefore ? [chosenBefore] : [])])];
319
+ const pick = await ui.select({
320
+ message: `Where is your ${p.label} API key?`,
321
+ initial: chosenBefore ? `var:${chosenBefore}` : known.find((name) => envValue(name)) ? `var:${known.find((name) => envValue(name))}` : "paste",
322
+ choices: [
323
+ ...known.map((name) => ({ value: `var:${name}`, label: name, hint: describe(name) })),
324
+ { value: "other", label: "Another variable…", hint: "you name it" },
325
+ { value: "paste", label: "Paste it now", hint: `kept in ${keyFile}, readable by you only` },
326
+ { value: "none", label: "No key for now", hint: "the computer's own voice speaks until there is one" },
327
+ ],
328
+ });
329
+ let keyVar = "";
330
+ let hasKey = false;
331
+ if (pick.startsWith("var:") || pick === "other") {
332
+ keyVar = pick === "other"
333
+ ? (await ui.text({ message: "Which variable?", placeholder: "MY_GEMINI_KEY", validate: (v) => (/^[A-Za-z_][A-Za-z0-9_]*$/.test(v) ? "" : "a variable name: letters, digits and underscores") })).toUpperCase()
334
+ : pick.slice(4);
335
+ hasKey = Boolean(envValue(keyVar));
336
+ if (hasKey) ui.done(`The key in ${keyVar} will be used`);
337
+ else ui.done(`${keyVar} is empty right now`, "set it and the voice will work; the computer's own voice speaks until then");
338
+ } else if (pick === "paste") {
339
+ const pasted = await ui.text({ message: `${p.label} API key`, mask: true, placeholder: "paste it, or leave empty for no key" });
284
340
  if (pasted) {
285
- const file = saveKey(p.keyVars[0], pasted);
286
- key = { name: p.keyVars[0], value: pasted };
287
- ui.done("Key saved", `${file}; setting ${p.keyVars[0]} in your environment works too`);
288
- } else {
289
- ui.done("No key", "the computer's own voice will speak until a key is set");
290
- }
341
+ saveKey(p.keyVars[0], pasted);
342
+ hasKey = true;
343
+ ui.done("Key saved", `${keyFile}; setting ${p.keyVars[0]} in your environment later works too`);
344
+ } else ui.done("Nothing pasted", "the computer's own voice speaks until a key is set");
345
+ } else {
346
+ ui.done("No key", "the computer's own voice speaks until a key is set");
291
347
  }
292
- const hasKey = Boolean(key.value);
348
+ // The named variable, or the provider's usual ones when the key was pasted or there is none.
349
+ await persist({ "speech-key-var": keyVar || "auto" });
293
350
 
294
351
  const all = catalog(provider).voices;
295
352
  const choice = (v) => ({
@@ -357,13 +414,34 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
357
414
  config ? "Settings from an earlier setup are the defaults below" : "First setup",
358
415
  ]);
359
416
 
417
+ // ---- the copy everything points at: the global one, if there is or is about to be one
418
+ let target = SKILL_DIR;
419
+ let hookScript = script;
420
+ const global = globalCopy(env);
421
+ const pick = pickTarget(SKILL_DIR, global);
422
+ if (pick.reason === "global") {
423
+ ui.done("The roger-roger command is on your PATH", global.dir);
424
+ } else if (pick.offer) {
425
+ const yes = await ui.confirm({ message: "Put the roger-roger command on your PATH?", hint: "npm install -g roger-roger, so you and your agents can run it from anywhere" });
426
+ if (yes) {
427
+ const done = await attempt(ui, "Installing globally", () => ui.run("Installing roger-roger with npm", () => installGlobally(env), { done: () => ({ text: "The roger-roger command is on your PATH", detail: global.dir }) }), { skipLabel: "Carry on with this copy" });
428
+ if (done !== null && fs.existsSync(path.join(global.dir, "scripts", "roger-roger.mjs"))) {
429
+ target = global.dir;
430
+ hookScript = path.join(global.dir, "scripts", "roger-roger.mjs");
431
+ // The daemon should come from the copy that stays, not from the one npx will throw away.
432
+ await daemon("stop").catch(() => {});
433
+ spawnSync(process.execPath, [hookScript, "daemon", "start"], { encoding: "utf8", windowsHide: true, timeout: 30_000, env });
434
+ }
435
+ }
436
+ }
437
+
360
438
  // ---- the skill, where agents look for it
361
439
  const wanted = await ui.multiselect({
362
440
  message: "Which agents should have the skill?",
363
441
  choices: agents.map((a) => ({ value: a.id, label: a.label, hint: a.hint ?? `~/${a.dir.join("/")}/skills`, checked: true })),
364
442
  });
365
443
  for (const a of agents.filter((x) => wanted.includes(x.id))) {
366
- const plan = planLink(a.link, SKILL_DIR);
444
+ const plan = planLink(a.link, target);
367
445
  if (plan.action === "replace") {
368
446
  const yes = await ui.confirm({ message: `${a.label} has its own copy of the skill. Replace it with a link?`, hint: "the copy is moved aside, not deleted" });
369
447
  if (!yes) {
@@ -376,7 +454,7 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
376
454
  continue;
377
455
  }
378
456
  try {
379
- const done = applyLink(a.link, SKILL_DIR, plan);
457
+ const done = applyLink(a.link, target, plan);
380
458
  ui.done(`${a.label}: ${plan.action === "none" ? plan.note : "linked"}`, `${a.link}${done.movedTo ? ` (the copy is at ${done.movedTo})` : ""}`);
381
459
  summary.push(a.label);
382
460
  } catch (e) {
@@ -389,7 +467,7 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
389
467
  if (herdr) {
390
468
  const yes = await ui.confirm({ message: "Register with Herdr as a plugin?", hint: "starts the daemon with Herdr, adds snooze to its actions" });
391
469
  if (yes) {
392
- const r = herdrLink(herdr, SKILL_DIR);
470
+ const r = herdrLink(herdr, target);
393
471
  if (r.ok) {
394
472
  ui.done("Registered with Herdr", r.note ?? "herdr plugin list shows it");
395
473
  herdrDone = true;
@@ -469,7 +547,7 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
469
547
  if (hookAgents.length && methods.includes("slack")) {
470
548
  const labels = { claude: "Claude Code", opencode: "OpenCode", codex: "Codex" };
471
549
  const installed = hookAgents.every(([, a]) => {
472
- const s = a.status({ script });
550
+ const s = a.status({ script: hookScript });
473
551
  return s.installed && !s.note;
474
552
  });
475
553
  if (installed) {
@@ -482,7 +560,7 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
482
560
  if (yes) {
483
561
  for (const [name, a] of hookAgents) {
484
562
  try {
485
- a.install({ script });
563
+ a.install({ script: hookScript });
486
564
  ui.done(`${labels[name] ?? name}: hooks installed`, name === "codex" ? "open /hooks in Codex once and trust the three roger-roger hooks" : name === "opencode" ? "restart OpenCode to load the plugin" : "");
487
565
  summary.push(`hooks: ${labels[name] ?? name}`);
488
566
  } catch (e) {
@@ -549,6 +627,266 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
549
627
  }
550
628
  }
551
629
 
630
+ // ---------------------------------------------------------------- uninstall
631
+
632
+ /** The skill as agents see it: links (always ours, by name) and folders that hold a SKILL.md. */
633
+ export function skillFootprint(home = os.homedir(), fsx = fs) {
634
+ return skillLinks(home).filter((a) => {
635
+ try {
636
+ const st = fsx.lstatSync(a.link);
637
+ return st.isSymbolicLink() || (st.isDirectory() && fsx.existsSync(path.join(a.link, "SKILL.md")));
638
+ } catch {
639
+ return false;
640
+ }
641
+ });
642
+ }
643
+
644
+ /** Remove a link, or a copy. */
645
+ export function removeSkillLink(link, fsx = fs) {
646
+ if (fsx.lstatSync(link).isSymbolicLink()) fsx.unlinkSync(link);
647
+ else fsx.rmSync(link, { recursive: true, force: true });
648
+ }
649
+
650
+ /** Whether Herdr has the plugin, so uninstall can say so rather than guess. */
651
+ export function herdrHasPlugin(herdr) {
652
+ try {
653
+ const r = spawnSync(herdr.command, ["plugin", "list", "--json"], { encoding: "utf8", windowsHide: true, timeout: 15_000 });
654
+ return (JSON.parse(r.stdout)?.result?.plugins ?? []).some((p) => p.plugin_id === "roger-roger");
655
+ } catch {
656
+ return false;
657
+ }
658
+ }
659
+
660
+ export function herdrUnlink(herdr) {
661
+ const r = spawnSync(herdr.command, ["plugin", "unlink", "roger-roger"], { encoding: "utf8", windowsHide: true, timeout: 20_000 });
662
+ if (r.status === 0) return { ok: true };
663
+ const text = `${r.stderr}${r.stdout}`.trim();
664
+ let message = text;
665
+ try {
666
+ message = JSON.parse(text)?.error?.message ?? text;
667
+ } catch {}
668
+ return /not (found|linked|installed)|unknown plugin/i.test(message) ? { ok: true, note: message } : { ok: false, error: message };
669
+ }
670
+
671
+ /** `npm uninstall -g roger-roger`. Throws with npm's last words when it fails. */
672
+ export function uninstallGlobally(env = process.env) {
673
+ const r = npmSync(["uninstall", "-g", "roger-roger", "--no-audit", "--no-fund", "--loglevel", "error"], env, 300_000);
674
+ if (r.status !== 0) throw new Error((r.stderr || r.stdout || r.error?.message || `npm exited with ${r.status}`).trim().split(/\r?\n/).slice(-3).join(" "));
675
+ return r.stdout.trim();
676
+ }
677
+
678
+ /** Where the Slack CLI that `install` put on the machine is, if it is there at all. */
679
+ export async function slackCliFootprint(home = os.homedir()) {
680
+ const dir = cliInstallDir();
681
+ const cli = await findCli();
682
+ const ours = Boolean(cli && norm(cli.command).startsWith(norm(dir)));
683
+ return { dir, login: path.join(home, ".slack"), cli, ours, present: ours || fs.existsSync(dir) };
684
+ }
685
+
686
+ /** Take the Slack CLI out: the binary, its login, and (Windows) the PATH entry `install` added. */
687
+ export function removeSlackCli({ home = os.homedir(), fsx = fs } = {}) {
688
+ const { dir, login } = { dir: cliInstallDir(), login: path.join(home, ".slack") };
689
+ fsx.rmSync(dir, { recursive: true, force: true });
690
+ fsx.rmSync(login, { recursive: true, force: true });
691
+ if (process.platform === "win32") {
692
+ spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command",
693
+ "$p = [Environment]::GetEnvironmentVariable('Path', 'User'); $b = Join-Path $env:AG_DIR 'bin'; " +
694
+ "$new = ($p -split ';' | Where-Object { $_ -and $_.ToLower() -ne $b.ToLower() }) -join ';'; " +
695
+ "if ($new -ne $p) { [Environment]::SetEnvironmentVariable('Path', $new, 'User') }",
696
+ ], { env: { ...process.env, AG_DIR: dir }, windowsHide: true, timeout: 20_000 });
697
+ } else {
698
+ const link = path.join(home, ".local", "bin", "slack");
699
+ try {
700
+ if (norm(fsx.readlinkSync(link)).startsWith(norm(dir))) fsx.unlinkSync(link);
701
+ } catch {}
702
+ }
703
+ }
704
+
705
+ /** Stop the tray and the daemon if one is running; never start one to do it. */
706
+ async function stopDaemon(env = process.env) {
707
+ let socket;
708
+ try {
709
+ socket = await connect({ start: false, env });
710
+ } catch {
711
+ return false;
712
+ }
713
+ try {
714
+ await request(socket, { cmd: "tray", args: { _: ["stop"] } }).catch(() => {});
715
+ await request(socket, { cmd: "stop" }).catch(() => {});
716
+ return true;
717
+ } finally {
718
+ socket.end();
719
+ }
720
+ }
721
+
722
+ /** What is on this machine, so uninstall can offer exactly that. */
723
+ export async function uninstallFindings({ home = os.homedir(), env = process.env, hooks = null, script = "" } = {}) {
724
+ const herdr = findHerdr(env);
725
+ const hookAgents = Object.entries(hooks?.agents ?? {}).filter(([, a]) => {
726
+ try {
727
+ return a.status({ script }).installed || a.status({ script }).partial;
728
+ } catch {
729
+ return false;
730
+ }
731
+ }).map(([name]) => name);
732
+ const global = globalCopy(env);
733
+ return {
734
+ links: skillFootprint(home),
735
+ herdr: herdr && herdrHasPlugin(herdr) ? herdr : null,
736
+ hookAgents,
737
+ home: [rogerRogerHome(env), path.join(home, ".attention")].filter((d) => fs.existsSync(d)),
738
+ global: global?.present ? global : null,
739
+ slackCli: await slackCliFootprint(home),
740
+ };
741
+ }
742
+
743
+ /** Everything but the Slack CLI, with no questions. Returns what was done. */
744
+ export async function quietUninstall({ home = os.homedir(), env = process.env, hooks = null, script = "", slackCli = false } = {}) {
745
+ const found = await uninstallFindings({ home, env, hooks, script });
746
+ const done = { daemonStopped: await stopDaemon(env), hooks: [], links: [], herdr: null, home: [], slackCli: false, global: null };
747
+ for (const name of found.hookAgents) {
748
+ try {
749
+ hooks.agents[name].uninstall();
750
+ done.hooks.push(name);
751
+ } catch (e) {
752
+ done.hooks.push({ agent: name, error: e.message });
753
+ }
754
+ }
755
+ if (found.herdr) done.herdr = herdrUnlink(found.herdr);
756
+ for (const a of found.links) {
757
+ try {
758
+ removeSkillLink(a.link);
759
+ done.links.push(a.link);
760
+ } catch (e) {
761
+ done.links.push({ link: a.link, error: e.message });
762
+ }
763
+ }
764
+ for (const dir of found.home) {
765
+ fs.rmSync(dir, { recursive: true, force: true });
766
+ done.home.push(dir);
767
+ }
768
+ if (slackCli && found.slackCli.present) {
769
+ removeSlackCli({ home });
770
+ done.slackCli = true;
771
+ }
772
+ if (found.global) {
773
+ try {
774
+ uninstallGlobally(env);
775
+ done.global = { removed: true, dir: found.global.dir };
776
+ } catch (e) {
777
+ done.global = { removed: false, error: e.message };
778
+ }
779
+ }
780
+ return done;
781
+ }
782
+
783
+ /** The conversation for taking it out. Returns the process exit code. */
784
+ export async function runUninstall(args, { script, hooks, input = process.stdin, output = process.stdout, env = process.env, home = os.homedir() } = {}) {
785
+ const ui = createUI({ input, output, env });
786
+ const c = ui.style;
787
+
788
+ if (args.yes || !ui.interactive) {
789
+ const done = await quietUninstall({ home, env, hooks, script, slackCli: Boolean(args["slack-cli"]) });
790
+ output.write(JSON.stringify({ ok: true, ...done }, null, 2) + "\n");
791
+ return 0;
792
+ }
793
+
794
+ try {
795
+ ui.intro(`roger-roger uninstall ${VERSION ? c.dim(VERSION) : ""}`, "Takes roger-roger off this machine. You choose what goes.");
796
+ const found = await ui.run("Looking around", () => uninstallFindings({ home, env, hooks, script }), { done: () => "Looked around" });
797
+ const labels = { claude: "Claude Code", opencode: "OpenCode", codex: "Codex" };
798
+ const choices = [];
799
+ if (found.links.length) choices.push({ value: "links", label: "The skill, for agents", hint: found.links.map((a) => a.label).join(", "), checked: true });
800
+ if (found.hookAgents.length) choices.push({ value: "hooks", label: "Terminal-question hooks", hint: found.hookAgents.map((n) => labels[n] ?? n).join(", "), checked: true });
801
+ if (found.herdr) choices.push({ value: "herdr", label: "The Herdr plugin", hint: "herdr plugin unlink", checked: true });
802
+ if (found.home.length) choices.push({ value: "home", label: "Settings, Slack tokens, state", hint: found.home.join(", "), checked: true });
803
+ if (found.global) choices.push({ value: "global", label: "The roger-roger command", hint: `npm uninstall -g (${found.global.dir})`, checked: true });
804
+ if (found.slackCli.present) choices.push({ value: "slack-cli", label: "The Slack CLI", hint: `${found.slackCli.dir} and its login in ~/.slack; Slack's tool, so off by default`, checked: false });
805
+ if (!choices.length) {
806
+ ui.outro([c.bold("Nothing of roger-roger's is on this machine."), "", "If it is somewhere unusual, remove it by hand, and see `roger-roger help uninstall`."]);
807
+ return 0;
808
+ }
809
+ const what = await ui.multiselect({ message: "What should go?", choices });
810
+ if (!what.length) {
811
+ ui.outro([c.bold("Nothing removed.")]);
812
+ return 0;
813
+ }
814
+ const sure = await ui.confirm({ message: "Remove that now?", hint: "there is no undo", initial: true });
815
+ if (!sure) {
816
+ ui.outro([c.bold("Nothing removed.")]);
817
+ return 0;
818
+ }
819
+
820
+ const removed = [];
821
+ if (await stopDaemon(env)) ui.done("Stopped the daemon and the tray");
822
+ if (what.includes("hooks")) {
823
+ for (const name of found.hookAgents) {
824
+ try {
825
+ hooks.agents[name].uninstall();
826
+ ui.done(`${labels[name] ?? name}: hooks removed`);
827
+ removed.push(`hooks: ${labels[name] ?? name}`);
828
+ } catch (e) {
829
+ ui.failed(`${labels[name] ?? name}: hooks not removed`, e.message);
830
+ }
831
+ }
832
+ }
833
+ if (what.includes("herdr")) {
834
+ const r = herdrUnlink(found.herdr);
835
+ if (r.ok) {
836
+ ui.done("Herdr plugin unlinked", r.note ?? "");
837
+ removed.push("Herdr plugin");
838
+ } else ui.failed("Herdr would not unlink the plugin", r.error);
839
+ }
840
+ if (what.includes("links")) {
841
+ for (const a of found.links) {
842
+ try {
843
+ removeSkillLink(a.link);
844
+ ui.done(`${a.label}: skill removed`, a.link);
845
+ removed.push(a.label);
846
+ } catch (e) {
847
+ ui.failed(`${a.label}: not removed`, e.message);
848
+ }
849
+ }
850
+ }
851
+ if (what.includes("home")) {
852
+ for (const dir of found.home) {
853
+ fs.rmSync(dir, { recursive: true, force: true });
854
+ ui.done("Removed", dir);
855
+ removed.push(dir);
856
+ }
857
+ }
858
+ if (what.includes("slack-cli")) {
859
+ removeSlackCli({ home });
860
+ ui.done("Slack CLI removed", `${found.slackCli.dir}, ~/.slack, and its PATH entry`);
861
+ removed.push("Slack CLI");
862
+ }
863
+ if (what.includes("global")) {
864
+ try {
865
+ await ui.run("Removing the roger-roger command", () => uninstallGlobally(env), { done: () => "The roger-roger command is gone" });
866
+ removed.push("the roger-roger command");
867
+ } catch {
868
+ // ui.run has already said what npm said.
869
+ }
870
+ }
871
+ ui.outro([
872
+ c.bold("Done."),
873
+ "",
874
+ ...removed.map((s) => `${c.green("•")} ${s}`),
875
+ "",
876
+ c.dim("Lines about roger-roger in your own CLAUDE.md or AGENTS.md are yours, so they were left alone."),
877
+ `Back any time: ${c.cyan("npx roger-roger@latest install")}`,
878
+ ]);
879
+ return 0;
880
+ } catch (e) {
881
+ if (e instanceof Cancelled) {
882
+ output.write(os.EOL + `${ui.rail.end} Stopped. Nothing more was removed.` + os.EOL + os.EOL);
883
+ return 130;
884
+ }
885
+ ui.failed("Uninstall stopped", e.message);
886
+ return 1;
887
+ }
888
+ }
889
+
552
890
  /** One line on what a test notification managed. */
553
891
  function describeResults(results = {}) {
554
892
  const parts = [];
@@ -13,6 +13,15 @@ import { detectAgent } from "./agent.mjs";
13
13
  export const SKILL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
14
14
  export const SOUNDS_DIR = path.join(SKILL_DIR, "sounds");
15
15
 
16
+ /** The package's version, when the skill sits inside the package; empty for a bare skills.sh copy. */
17
+ export const VERSION = (() => {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(path.join(SKILL_DIR, "..", "..", "package.json"), "utf8")).version ?? "";
20
+ } catch {
21
+ return "";
22
+ }
23
+ })();
24
+
16
25
  export const METHODS = ["slack", "sound", "speech"];
17
26
  export const KINDS = ["done", "blocked", "input", "milestone", "error", "info"];
18
27
  export const SPEECH_MODES = ["auto", "same", "brief"];
@@ -30,7 +30,8 @@ import { fileURLToPath } from "node:url";
30
30
  import { claudeHookRequest, claudeSettingsPath, claudeStatus, installClaude, readStdin, uninstallClaude } from "./hooks.mjs";
31
31
  import { codexHome, codexHookRequest, codexStatus, installCodex, uninstallCodex } from "./hooks-codex.mjs";
32
32
  import { installOpencode, opencodeConfigDir, opencodeHookRequest, opencodeStatus, uninstallOpencode } from "./hooks-opencode.mjs";
33
- import { runInstall } from "./install.mjs";
33
+ import { runInstall, runUninstall } from "./install.mjs";
34
+ import { helpText } from "./help.mjs";
34
35
 
35
36
  function out(obj, code = 0) {
36
37
  process.stdout.write(JSON.stringify(obj, null, 2) + "\n");
@@ -321,6 +322,17 @@ const AGENTS = {
321
322
  },
322
323
  };
323
324
 
325
+ /** `help`, `help <command>`, `--help`, or a command that doesn't exist: the overview, or one command's page. */
326
+ function help(command, args) {
327
+ const wanted = command === "help" ? args._[0] : "";
328
+ const unknown = command && !["help", "--help", "-h", undefined].includes(command);
329
+ if (unknown) process.stderr.write(`roger-roger: unknown command "${command}"\n`);
330
+ const page = wanted ? helpText(wanted) : null;
331
+ if (wanted && !page) process.stderr.write(`roger-roger: no such command "${wanted}"\n`);
332
+ process.stdout.write(page ?? helpText());
333
+ process.exitCode = unknown || (wanted && !page) ? 2 : 0;
334
+ }
335
+
324
336
  /** Ask the daemon, or do it here when the daemon is from a copy of the skill that predates hooks. */
325
337
  async function sendPermission(args, session) {
326
338
  try {
@@ -409,68 +421,7 @@ function hooksCommand(args) {
409
421
  return out({ ok: !failed, agents: results }, failed ? 1 : 0);
410
422
  }
411
423
 
412
- const HELP = `roger-roger - bring the user back to the conversation
413
-
414
- install [--yes] set everything up in the terminal: link the skill for every agent,
415
- connect Slack, choose the sound and the voice, hooks, tray.
416
- --yes (or no terminal) only links the skill and registers with Herdr
417
- status config, daemon, running sessions, every valid option (JSON)
418
- setup [--methods slack,sound,speech] [--sound NAME] [--voice NAME]
419
- [--speech auto|same|brief] [--when auto|done-and-blocked|blocked-only|on-request]
420
- [--slack-target U0123|C0123|#channel] [--slack-mention U0123]
421
- [--local-fallback true|false] [--model ID] [--notes TEXT]
422
- [--remind 15m|off] [--on-expire recommended|nothing] [--say-who on|off]
423
- [--quiet-hours 22:00-08:00|off] [--quiet-mute speech,sound,slack] [--session-label on|off]
424
- [--speech-provider gemini|openai|elevenlabs] [--speech-key-var NAME|auto]
425
- [--speech-volume 0-150] [--duck on|off] [--duck-level 0-100] [--tray auto|off]
426
- [--permission-pings on|off] [--terminal-wait 20s]
427
- slack-setup [status|install-cli|login|create]
428
- create the roger-roger Slack app with the Slack CLI
429
-
430
- notify --kind done|blocked|input|milestone|error|info --message TEXT
431
- [--say TEXT] [--only sound,speech] [--project NAME] [--attach FILE]
432
- [--summary] [--field "Label=value"] [--actions "A|B"] [--recommend A]
433
- [--reply-to TS] [--then-listen 25m]
434
- [--session NAME] [--agent-model MODEL]
435
- progress --message TEXT [--key NAME] [--done]
436
- one Slack message, edited in place as the work moves on
437
- control [--key NAME] [--wait DURATION]
438
- read Pause / Stop and new messages without posting
439
- inbox messages the user sent this session, and anything unclaimed
440
- snooze [1h|off] mute the sound and the speech for a while; Slack keeps working
441
- tray status|start|stop|install an icon showing every agent, and a panel behind it
442
- listen [--wait 25m] park until the user says something; run it in the background as
443
- the last thing you do, so a message reaches you while you're idle
444
-
445
- ask --question TEXT --choices "A|B" [--recommend A] [--details TEXT] [--multi]
446
- [--draft TEXT|--draft-file PATH] [--expires 45m] [--remind 15m]
447
- [--on-expire recommended|nothing] [--other true|false] [--wait [DURATION]] [--reply-to TS]
448
- wait ID [--timeout DURATION] block until answered/expired/cancelled (exit 0 / 5, 4 = timed out,
449
- 6 = the user sent a message instead)
450
- check ID the current state, without waiting
451
- cancel ID [--reason TEXT] withdraw a question
452
- decisions [--all] pending questions (and recent closed ones with --all)
453
-
454
- sessions [--all] which agents are running, and what each is called
455
- sessions rename NAME NEWNAME give a session a different colour name
456
- end [NAME] this session (or NAME) is finished; stop routing messages to it
457
-
458
- play [SOUND] preview a sound
459
- say TEXT [--voice NAME] [--local | --test]
460
- --test checks the provider and key: its voice or an error, never the local one
461
- voices [--refresh] the speech provider's voices and models (--refresh asks the provider)
462
- preview-sounds [SOUND...] [--voice NAME] [--local]
463
- announce each sound by name, then play it
464
- preview-voices [VOICE...] [--all] [--text TEXT]
465
-
466
- daemon status|start|stop the always-on process behind all of the above
467
- hooks status|install|uninstall [claude|opencode|all]
468
- ping you when an agent asks a question in the terminal
469
-
470
- Any command takes --session NAME (what this work is called, and how the session is recognised
471
- between commands), --agent-model MODEL, --agent NAME, and, if the agent knows better than the
472
- environment does, --session-id ID and --agent-pid PID.
473
- `;
424
+ // The help text lives in help.mjs, laid out for the terminal.
474
425
 
475
426
  async function main() {
476
427
  const [command, ...rest] = process.argv.slice(2);
@@ -486,6 +437,9 @@ async function main() {
486
437
  case "install":
487
438
  process.exitCode = await runInstall(args, { script: SCRIPT, hooks: { agents: AGENTS } });
488
439
  return;
440
+ case "uninstall":
441
+ process.exitCode = await runUninstall(args, { script: SCRIPT, hooks: { agents: AGENTS } });
442
+ return;
489
443
  case "status": return status();
490
444
  case "setup": return setup(args);
491
445
  case "slack-setup": return slackSetup(args);
@@ -521,8 +475,7 @@ async function main() {
521
475
  return run(command, args);
522
476
 
523
477
  default:
524
- process.stdout.write(HELP);
525
- process.exitCode = command && command !== "help" ? 2 : 0;
478
+ return help(command, args);
526
479
  }
527
480
  }
528
481
 
@@ -9,18 +9,24 @@ import { spawn } from "node:child_process";
9
9
  const METADATA_URL = "https://docs.slack.dev/tools/metadata.json";
10
10
  const DOWNLOADS = "https://downloads.slack-edge.com/slack-cli";
11
11
 
12
+ /**
13
+ * On Windows the Slack CLI hangs for ever when it shares the caller's console (seen from agent
14
+ * shells) and runs normally without one, so it is started detached. Nothing else is: a detached
15
+ * PowerShell comes back at once with exit code 0 and no output, before it has done anything, which
16
+ * is how an install once "succeeded" into an empty folder.
17
+ */
18
+ export const detachOnWindows = (cmd) => process.platform === "win32" && /^slack(cli)?(\.exe|\.cmd)?$/i.test(path.basename(String(cmd)));
19
+
12
20
  /** Run a command and collect its output. Never rejects; `code` is null if it couldn't start. */
13
- export function capture(cmd, args, { input, env, cwd, timeout = 120_000 } = {}) {
21
+ export function capture(cmd, args, { input, env, cwd, timeout = 120_000, detached = detachOnWindows(cmd) } = {}) {
14
22
  return new Promise((resolve) => {
15
23
  let child;
16
24
  try {
17
- // Detached: on Windows the Slack CLI can hang indefinitely when it shares the caller's console
18
- // (seen from agent shells); without a console it runs normally. Output is still piped back.
19
25
  child = spawn(cmd, args, {
20
26
  env: env ?? process.env,
21
27
  cwd,
22
28
  timeout,
23
- detached: process.platform === "win32",
29
+ detached,
24
30
  windowsHide: true,
25
31
  stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
26
32
  });
@@ -38,6 +44,10 @@ export function capture(cmd, args, { input, env, cwd, timeout = 120_000 } = {})
38
44
  }
39
45
 
40
46
  /** Where the official installers put the binary, per platform. */
47
+ export function cliInstallDir() {
48
+ return installDir();
49
+ }
50
+
41
51
  function installDir() {
42
52
  if (process.platform === "win32") return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "slack-cli");
43
53
  return path.join(os.homedir(), ".slack");