roger-roger 0.1.0 → 0.1.1

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,15 @@ It works with any agent that supports [Agent Skills](https://skills.sh), such as
6
6
 
7
7
  ## Install
8
8
 
9
- ```sh
10
- npm install -g roger-roger
11
- roger-roger install
12
- ```
13
-
14
- Or, without installing anything permanently:
9
+ One command, in any shell on Windows, macOS or Linux:
15
10
 
16
11
  ```sh
17
- npx roger-roger install
12
+ npx roger-roger@latest install
18
13
  ```
19
14
 
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.
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.
16
+
17
+ If you'd rather install first and set up after, that works too: `npm install -g roger-roger`, then `roger-roger install`.
21
18
 
22
19
  Other ways in:
23
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roger-roger",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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
 
@@ -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.1"
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"]
@@ -103,6 +103,52 @@ export function applyLink(link, target, plan, fsx = fs, now = new Date()) {
103
103
  return { ...plan, done: true };
104
104
  }
105
105
 
106
+ // ---------------------------------------------------------------- the global copy
107
+
108
+ /**
109
+ * Run npm with fixed arguments (never anything typed). On Windows npm is a .cmd, which Node only
110
+ * runs through a shell, and a shell wants one command line rather than an argument list.
111
+ */
112
+ function npmSync(argv, env, timeout) {
113
+ const options = { encoding: "utf8", windowsHide: true, timeout, env };
114
+ return process.platform === "win32"
115
+ ? spawnSync(["npm", ...argv].join(" "), { ...options, shell: true })
116
+ : spawnSync("npm", argv, options);
117
+ }
118
+
119
+ /** Where npm keeps global packages, and whether roger-roger is among them. Null when npm isn't around. */
120
+ export function globalCopy(env = process.env) {
121
+ try {
122
+ const r = npmSync(["root", "-g"], env, 20_000);
123
+ const root = (r.stdout ?? "").trim();
124
+ if (r.status !== 0 || !root) return null;
125
+ const pkg = path.join(root, "roger-roger");
126
+ return { root, dir: path.join(pkg, "skills", "roger-roger"), present: fs.existsSync(path.join(pkg, "package.json")) };
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Which copy of the skill everything should point at, and whether to offer a global install:
134
+ * the copy this runs from, unless that is a passing one (npx's cache, say) and there is no global
135
+ * copy yet. A global copy that exists beside a checkout or a skills.sh copy is left alone: whoever
136
+ * runs this copy meant this copy.
137
+ */
138
+ export function pickTarget(running, global) {
139
+ if (!global) return { target: running, offer: false, reason: "npm was not found" };
140
+ if (norm(running) === norm(global.dir)) return { target: running, offer: false, reason: "global" };
141
+ if (global.present) return { target: running, offer: false, reason: "a global copy exists, but this one was run" };
142
+ return { target: running, offer: true, reason: "no global copy yet" };
143
+ }
144
+
145
+ /** `npm install -g roger-roger@latest`. Throws with npm's last words when it fails. */
146
+ export function installGlobally(env = process.env) {
147
+ const r = npmSync(["install", "-g", "roger-roger@latest", "--no-audit", "--no-fund", "--loglevel", "error"], env, 300_000);
148
+ 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(" "));
149
+ return r.stdout.trim();
150
+ }
151
+
106
152
  // ---------------------------------------------------------------- herdr
107
153
 
108
154
  /** The `herdr` binary, from the pane's environment or PATH: `{ command, version }`, or null. */
@@ -357,13 +403,34 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
357
403
  config ? "Settings from an earlier setup are the defaults below" : "First setup",
358
404
  ]);
359
405
 
406
+ // ---- the copy everything points at: the global one, if there is or is about to be one
407
+ let target = SKILL_DIR;
408
+ let hookScript = script;
409
+ const global = globalCopy(env);
410
+ const pick = pickTarget(SKILL_DIR, global);
411
+ if (pick.reason === "global") {
412
+ ui.done("The roger-roger command is on your PATH", global.dir);
413
+ } else if (pick.offer) {
414
+ 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" });
415
+ if (yes) {
416
+ 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" });
417
+ if (done !== null && fs.existsSync(path.join(global.dir, "scripts", "roger-roger.mjs"))) {
418
+ target = global.dir;
419
+ hookScript = path.join(global.dir, "scripts", "roger-roger.mjs");
420
+ // The daemon should come from the copy that stays, not from the one npx will throw away.
421
+ await daemon("stop").catch(() => {});
422
+ spawnSync(process.execPath, [hookScript, "daemon", "start"], { encoding: "utf8", windowsHide: true, timeout: 30_000, env });
423
+ }
424
+ }
425
+ }
426
+
360
427
  // ---- the skill, where agents look for it
361
428
  const wanted = await ui.multiselect({
362
429
  message: "Which agents should have the skill?",
363
430
  choices: agents.map((a) => ({ value: a.id, label: a.label, hint: a.hint ?? `~/${a.dir.join("/")}/skills`, checked: true })),
364
431
  });
365
432
  for (const a of agents.filter((x) => wanted.includes(x.id))) {
366
- const plan = planLink(a.link, SKILL_DIR);
433
+ const plan = planLink(a.link, target);
367
434
  if (plan.action === "replace") {
368
435
  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
436
  if (!yes) {
@@ -376,7 +443,7 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
376
443
  continue;
377
444
  }
378
445
  try {
379
- const done = applyLink(a.link, SKILL_DIR, plan);
446
+ const done = applyLink(a.link, target, plan);
380
447
  ui.done(`${a.label}: ${plan.action === "none" ? plan.note : "linked"}`, `${a.link}${done.movedTo ? ` (the copy is at ${done.movedTo})` : ""}`);
381
448
  summary.push(a.label);
382
449
  } catch (e) {
@@ -389,7 +456,7 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
389
456
  if (herdr) {
390
457
  const yes = await ui.confirm({ message: "Register with Herdr as a plugin?", hint: "starts the daemon with Herdr, adds snooze to its actions" });
391
458
  if (yes) {
392
- const r = herdrLink(herdr, SKILL_DIR);
459
+ const r = herdrLink(herdr, target);
393
460
  if (r.ok) {
394
461
  ui.done("Registered with Herdr", r.note ?? "herdr plugin list shows it");
395
462
  herdrDone = true;
@@ -469,7 +536,7 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
469
536
  if (hookAgents.length && methods.includes("slack")) {
470
537
  const labels = { claude: "Claude Code", opencode: "OpenCode", codex: "Codex" };
471
538
  const installed = hookAgents.every(([, a]) => {
472
- const s = a.status({ script });
539
+ const s = a.status({ script: hookScript });
473
540
  return s.installed && !s.note;
474
541
  });
475
542
  if (installed) {
@@ -482,7 +549,7 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
482
549
  if (yes) {
483
550
  for (const [name, a] of hookAgents) {
484
551
  try {
485
- a.install({ script });
552
+ a.install({ script: hookScript });
486
553
  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
554
  summary.push(`hooks: ${labels[name] ?? name}`);
488
555
  } catch (e) {
@@ -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
  });