clauderipple 0.10.0 → 0.11.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 (78) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.es.md +45 -0
  3. package/README.ja.md +17 -0
  4. package/README.ko.md +31 -0
  5. package/README.md +39 -0
  6. package/README.pt-BR.md +45 -0
  7. package/README.zh-CN.md +17 -0
  8. package/dist/app/assets/icon.icns +0 -0
  9. package/dist/app/assets/icon.ico +0 -0
  10. package/dist/app/assets/icon.png +0 -0
  11. package/dist/app/assets/trayDirectTemplate.png +0 -0
  12. package/dist/app/assets/trayDirectTemplate@2x.png +0 -0
  13. package/dist/app/assets/trayDownTemplate.png +0 -0
  14. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  15. package/dist/app/assets/trayTemplate.png +0 -0
  16. package/dist/app/assets/trayTemplate@2x.png +0 -0
  17. package/dist/app/assets/trayWarnTemplate.png +0 -0
  18. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  19. package/dist/app/assets/trayWin.png +0 -0
  20. package/dist/app/assets/trayWin@2x.png +0 -0
  21. package/dist/app/assets/trayWinDirect.png +0 -0
  22. package/dist/app/assets/trayWinDirect@2x.png +0 -0
  23. package/dist/app/assets/trayWinDown.png +0 -0
  24. package/dist/app/assets/trayWinDown@2x.png +0 -0
  25. package/dist/app/assets/trayWinWarn.png +0 -0
  26. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  27. package/dist/app/dist/claude-desktop.js +67 -0
  28. package/dist/app/dist/main.js +423 -110
  29. package/dist/app/dist/preload.js +21 -0
  30. package/dist/app/dist/protocol.js +121 -0
  31. package/dist/app/dist/status-model.js +87 -0
  32. package/dist/app/dist/window.js +133 -0
  33. package/dist/app/package.json +1 -1
  34. package/dist/cli/src/awake-setup.js +314 -0
  35. package/dist/cli/src/bundle.js +9 -5
  36. package/dist/cli/src/direct.js +159 -0
  37. package/dist/cli/src/doctor.js +205 -0
  38. package/dist/cli/src/index.js +184 -31
  39. package/dist/cli/src/launchd.js +29 -20
  40. package/dist/cli/src/launcher.js +215 -0
  41. package/dist/cli/src/power.js +182 -0
  42. package/dist/cli/src/registration.js +157 -0
  43. package/dist/cli/src/schtasks.js +2 -0
  44. package/dist/cli/src/settings.js +24 -0
  45. package/dist/cli/src/tray.js +5 -2
  46. package/dist/mod/hooks/i18n.ts +237 -237
  47. package/dist/mod/hooks/register.tsx +4 -5
  48. package/dist/mod/hooks/session.ts +6 -2
  49. package/dist/mod/hooks/ui/agents.tsx +81 -36
  50. package/dist/mod/hooks/ui/view.ts +8 -5
  51. package/dist/mod/tests/ui.test.tsx +46 -13
  52. package/dist/mod/types/index.d.ts +0 -2
  53. package/dist/remote/src/bridge-client.js +45 -4
  54. package/dist/router/src/admin.js +105 -2
  55. package/dist/router/src/awake-win.js +590 -0
  56. package/dist/router/src/awake.js +432 -0
  57. package/dist/router/src/config.js +64 -0
  58. package/dist/router/src/health.js +86 -3
  59. package/dist/router/src/hold.js +107 -0
  60. package/dist/router/src/index.js +69 -6
  61. package/dist/router/src/ingress/server.js +26 -4
  62. package/dist/router/src/power.js +107 -0
  63. package/dist/router/src/providers/google/antigravity.js +73 -10
  64. package/dist/router/src/providers/google/index.js +168 -17
  65. package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
  66. package/dist/router/src/providers/google/transport.js +1 -1
  67. package/dist/router/src/providers/google/usage.js +71 -26
  68. package/dist/router/src/providers/retry.js +4 -1
  69. package/dist/router/src/proxy.js +50 -21
  70. package/dist/router/src/tether-win.js +211 -0
  71. package/dist/router/src/tether.js +712 -0
  72. package/dist/router/src/version.js +1 -1
  73. package/dist/ui/app.js +649 -2
  74. package/dist/ui/i18n.js +494 -77
  75. package/dist/ui/index.html +9 -1
  76. package/dist/ui/style.css +138 -0
  77. package/docs/ARCHITECTURE.md +294 -6
  78. package/package.json +2 -2
@@ -0,0 +1,314 @@
1
+ // `clauderipple awake setup [--ssid NAME] [--remove]` — the one privileged step behind lid-closed
2
+ // awake and hotspot failover (router: awake.ts, tether.ts).
3
+ //
4
+ // It installs /etc/sudoers.d/clauderipple, which lets this user run exactly these as root, without a
5
+ // password (the router only ever uses `sudo -n`, which fails rather than prompting):
6
+ // /usr/bin/pmset -a disablesleep 0 | 1 hold a closed MacBook awake, and release it
7
+ // /usr/bin/pmset sleepnow put a closed Mac to sleep when we release it
8
+ // /usr/sbin/networksetup -setairportnetwork <wifi device> <hotspot name> (only with a hotspot name)
9
+ // The grant is per user, not per app (Insomnia's README says the same of its own rule).
10
+ //
11
+ // The rule text is built here and written to a temp file by this (unprivileged) process; the one
12
+ // privileged step is a single `osascript … do shell script … with administrator privileges`, so the
13
+ // user types their password into macOS's own dialog. That script copies the file root-owned, checks
14
+ // its SHA-256 against what was built (nothing can swap it in between), checks it with `visudo -cf`,
15
+ // moves it into place, and checks the whole sudoers config again — removing ours if that fails, so a
16
+ // broken rule can never lock the user out of sudo. Only then does it clear Insomnia's privileged
17
+ // leftovers; the rest of Insomnia is removed unprivileged afterwards. Everything is built as data
18
+ // and unit-tested as text: no SSID ever reaches a shell (it is validated, then only written to the
19
+ // file), and every path in the script is single-quoted.
20
+ //
21
+ // Windows has no privileged step (docs/ARCHITECTURE.md §8, Windows): power plans carry one ACL that
22
+ // gives Authenticated Users read/write, and joining a saved Wi-Fi profile is a user action. `awake
23
+ // setup` there checks instead of installing: that this account may change the lid-close action (a
24
+ // write of the values already there — a Group Policy override refuses it), and with `--ssid` that the
25
+ // Wi-Fi adapter, the hotspot's saved profile and an automatic profile to come back to exist.
26
+ import crypto from "node:crypto";
27
+ import fs from "node:fs";
28
+ import os from "node:os";
29
+ import path from "node:path";
30
+ import { ssidProblem } from "../../router/src/config.js";
31
+ import { PMSET, SUDO, SUDOERS_FILE, readJournal } from "../../router/src/awake.js";
32
+ import { POWERSHELL, powershellEnv, psArgs, readLidAction, restoreLidFromJournal, writeLidAction } from "../../router/src/awake-win.js";
33
+ import { NETWORKSETUP, parseWifiDevice } from "../../router/src/tether.js";
34
+ import { FIND_WIFI_PS, WIN_HINTS, autoProfiles, hotspotProfile, listWifiProfiles, parseAdapterName } from "../../router/src/tether-win.js";
35
+ export const OSASCRIPT = "/usr/bin/osascript";
36
+ const STAGED = "/etc/sudoers.d/.clauderipple.new"; // sudo ignores names containing a dot
37
+ const INSOMNIA_SUDOERS = "/etc/sudoers.d/insomnia";
38
+ const INSOMNIA_LABEL = "com.insomnia.backstop";
39
+ const INSOMNIA_KEYCHAIN_SERVICE = "insomnia-hotspot";
40
+ /** POSIX single quotes: the only character that needs care inside them is the quote itself. */
41
+ export function shQuote(s) {
42
+ return `'${s.replace(/'/g, `'\\''`)}'`;
43
+ }
44
+ /** An AppleScript string literal. */
45
+ export function asString(s) {
46
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
47
+ }
48
+ export function userProblem(user) {
49
+ return /^[A-Za-z_][A-Za-z0-9_.-]{0,31}$/.test(user) ? null : `unexpected user name "${user}"`;
50
+ }
51
+ /** The sudoers drop-in. Throws on anything that could not be written literally. */
52
+ export function sudoersRule(o) {
53
+ const bad = userProblem(o.user);
54
+ if (bad)
55
+ throw new Error(bad);
56
+ const commands = [`${PMSET} -a disablesleep 0`, `${PMSET} -a disablesleep 1`, `${PMSET} sleepnow`];
57
+ if (o.ssid !== undefined) {
58
+ const problem = ssidProblem(o.ssid);
59
+ if (problem)
60
+ throw new Error(problem);
61
+ if (!o.wifiDevice || !/^en\d+$/.test(o.wifiDevice))
62
+ throw new Error(`unexpected Wi-Fi device "${o.wifiDevice ?? ""}"`);
63
+ commands.push(`${NETWORKSETUP} -setairportnetwork ${o.wifiDevice} ${o.ssid}`);
64
+ }
65
+ return [
66
+ "# ClaudeRipple: keep the Mac awake with the lid closed, and join the phone hotspot.",
67
+ "# Installed by `clauderipple awake setup`; removed by `clauderipple awake setup --remove`.",
68
+ `${o.user} ALL=(root) NOPASSWD: ${commands.join(", ")}`,
69
+ "",
70
+ ].join("\n");
71
+ }
72
+ function pathProblem(p) {
73
+ return /^\/[A-Za-z0-9/._-]+$/.test(p) ? null : `unexpected characters in path ${p}`;
74
+ }
75
+ /** The privileged shell script for install. One line, run by `do shell script` (/bin/sh). */
76
+ export function installScript(o) {
77
+ const bad = pathProblem(o.tempFile);
78
+ if (bad)
79
+ throw new Error(bad);
80
+ if (!/^[0-9a-f]{64}$/.test(o.sha256))
81
+ throw new Error("bad sha256");
82
+ const undoStaged = `{ /bin/rm -f ${shQuote(STAGED)}; exit 3; }`;
83
+ const steps = [
84
+ "set -e",
85
+ `/usr/bin/install -m 0440 -o root -g wheel ${shQuote(o.tempFile)} ${shQuote(STAGED)}`,
86
+ `[ "$(/usr/bin/shasum -a 256 ${shQuote(STAGED)} | /usr/bin/cut -c1-64)" = ${shQuote(o.sha256)} ] || ${undoStaged}`,
87
+ `/usr/sbin/visudo -cf ${shQuote(STAGED)} || ${undoStaged}`,
88
+ `/bin/mv -f ${shQuote(STAGED)} ${shQuote(SUDOERS_FILE)}`,
89
+ `/usr/sbin/visudo -c || { /bin/rm -f ${shQuote(SUDOERS_FILE)}; exit 4; }`,
90
+ ];
91
+ // Insomnia's privileged leftovers, only after ours is safely in place: clear the sleep bit it may
92
+ // have left set (its backstop is about to go), then its rule.
93
+ if (o.removeInsomnia)
94
+ steps.push(`${PMSET} -a disablesleep 0`, `/bin/rm -f ${shQuote(INSOMNIA_SUDOERS)}`);
95
+ return steps.join("; ");
96
+ }
97
+ /** The privileged shell script for `--remove`. Clears the bit only if our journal says we set it. */
98
+ export function removeScript(o) {
99
+ const steps = ["set -e"];
100
+ if (o.clearSleepBit)
101
+ steps.push(`${PMSET} -a disablesleep 0`);
102
+ steps.push(`/bin/rm -f ${shQuote(SUDOERS_FILE)} ${shQuote(STAGED)}`);
103
+ return steps.join("; ");
104
+ }
105
+ /** `osascript` arguments that run `script` as root after macOS's own password dialog. */
106
+ export function adminOsascriptArgs(script, prompt) {
107
+ return ["-e", `do shell script ${asString(script)} with prompt ${asString(prompt)} with administrator privileges`];
108
+ }
109
+ export function insomniaPaths(home) {
110
+ return {
111
+ plist: path.join(home, "Library", "LaunchAgents", `${INSOMNIA_LABEL}.plist`),
112
+ app: path.join(home, "Applications", "Insomnia.app"),
113
+ };
114
+ }
115
+ /** Insomnia's unprivileged leftovers, in order. Failures are reported, not fatal. */
116
+ export function insomniaCleanupSteps(o) {
117
+ const p = insomniaPaths(o.home);
118
+ const steps = [{ label: "stop Insomnia's backstop agent", kind: "run", file: "/bin/launchctl", args: ["bootout", `gui/${o.uid}/${INSOMNIA_LABEL}`] }];
119
+ if (o.exists(p.plist))
120
+ steps.push({ label: "move the backstop agent's plist to the Trash", kind: "trash", path: p.plist });
121
+ steps.push({ label: "quit Insomnia", kind: "run", file: "/usr/bin/killall", args: ["-TERM", "Insomnia"] });
122
+ if (o.exists(p.app))
123
+ steps.push({ label: "move Insomnia.app to the Trash", kind: "trash", path: p.app });
124
+ steps.push({ label: "delete Insomnia's hotspot password from the keychain", kind: "run", file: "/usr/bin/security", args: ["delete-generic-password", "-s", INSOMNIA_KEYCHAIN_SERVICE] });
125
+ return steps;
126
+ }
127
+ export function insomniaPresent(home, exists) {
128
+ const p = insomniaPaths(home);
129
+ return exists(INSOMNIA_SUDOERS) || exists(p.plist) || exists(p.app);
130
+ }
131
+ export function defaultSetupDeps(run, rippleHome) {
132
+ const info = os.userInfo();
133
+ return {
134
+ platform: process.platform,
135
+ run,
136
+ user: info.username,
137
+ uid: info.uid,
138
+ home: os.homedir(),
139
+ rippleHome,
140
+ exists: (p) => fs.existsSync(p),
141
+ writeTemp: (content) => {
142
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clauderipple-sudoers-"));
143
+ const file = path.join(dir, "clauderipple");
144
+ fs.writeFileSync(file, content, { mode: 0o644 });
145
+ return file;
146
+ },
147
+ removeTemp: (file) => fs.rmSync(path.dirname(file), { recursive: true, force: true }),
148
+ trashFallback: (p) => {
149
+ const trash = path.join(os.homedir(), ".Trash");
150
+ let target = path.join(trash, path.basename(p));
151
+ if (fs.existsSync(target))
152
+ target = path.join(trash, `${path.basename(p, path.extname(p))} ${Date.now()}${path.extname(p)}`);
153
+ fs.renameSync(p, target);
154
+ },
155
+ print: (line) => console.log(line),
156
+ };
157
+ }
158
+ const LID_NAMES = ["Do nothing", "Sleep", "Hibernate", "Shut down"];
159
+ /** Windows: nothing to install; check what the router will need, and say what is missing. */
160
+ async function windowsSetup(o, d) {
161
+ if (o.ssid !== undefined) {
162
+ const problem = ssidProblem(o.ssid);
163
+ if (problem)
164
+ return { ok: false, detail: problem };
165
+ }
166
+ const { lid, error } = await readLidAction(d.run);
167
+ if (lid) {
168
+ // Writing back the values already there changes nothing and proves the write is allowed.
169
+ const err = await writeLidAction(d.run, lid.scheme, lid.ac, lid.dc);
170
+ if (err)
171
+ return { ok: false, detail: `Windows does not let this account change the power plan's lid-close action (a Group Policy setting?): ${err}` };
172
+ d.print(`✓ the lid-close action can be changed without administrator rights (now: plugged in ${LID_NAMES[lid.ac]}, on battery ${LID_NAMES[lid.dc]}); ClaudeRipple sets "Do nothing" only while it holds the PC awake, and puts these back`);
173
+ }
174
+ else
175
+ d.print(`· this PC has no lid-close setting${error ? ` (${error})` : ""}: only idle sleep is held off`);
176
+ let wifiDevice;
177
+ if (o.ssid !== undefined) {
178
+ const r = await d.run(POWERSHELL, psArgs(FIND_WIFI_PS), { env: powershellEnv(), timeoutMs: 20_000 });
179
+ wifiDevice = (r.code === 0 ? parseAdapterName(r.stdout) : null) ?? undefined;
180
+ if (!wifiDevice)
181
+ return { ok: false, detail: "no Wi-Fi adapter found (Get-NetAdapter)" };
182
+ const list = await listWifiProfiles(d.run);
183
+ if (list.location)
184
+ d.print(`· ${WIN_HINTS["needs-location"]}`);
185
+ else if (list.error)
186
+ d.print(`· could not list the saved Wi-Fi networks: ${list.error}`);
187
+ else {
188
+ const hotspot = hotspotProfile(list.profiles, o.ssid);
189
+ d.print(hotspot ? `✓ Windows has joined "${o.ssid}" before (saved profile "${hotspot.name}")` : `· ${WIN_HINTS["no-hotspot-profile"]}`);
190
+ const autos = autoProfiles(list.profiles, o.ssid);
191
+ d.print(autos.length
192
+ ? `✓ ${autos.length} other Wi-Fi network${autos.length === 1 ? "" : "s"} connect${autos.length === 1 ? "s" : ""} automatically, so ClaudeRipple can come back from the hotspot`
193
+ : "· no other Wi-Fi on this PC is set to connect automatically, so ClaudeRipple would stay on the hotspot");
194
+ if (hotspot?.auto)
195
+ d.print(`· the hotspot's profile connects automatically too, so Windows may pick it over your Wi-Fi when coming back; \`netsh wlan set profileparameter name="${hotspot.name}" connectionmode=manual\` stops that (ClaudeRipple joins it by name either way)`);
196
+ }
197
+ }
198
+ return { ok: true, detail: "setup complete (Windows needs no administrator step)", ...(wifiDevice ? { wifiDevice } : {}) };
199
+ }
200
+ export async function awakeSetup(o, d) {
201
+ if (d.platform === "win32")
202
+ return windowsSetup(o, d);
203
+ if (d.platform !== "darwin")
204
+ return { ok: false, detail: "lid-closed awake and hotspot failover are macOS and Windows only" };
205
+ let wifiDevice;
206
+ if (o.ssid !== undefined) {
207
+ const problem = ssidProblem(o.ssid);
208
+ if (problem)
209
+ return { ok: false, detail: problem };
210
+ const ports = await d.run(NETWORKSETUP, ["-listallhardwareports"]);
211
+ wifiDevice = (ports.code === 0 ? parseWifiDevice(ports.stdout) : null) ?? undefined;
212
+ if (!wifiDevice)
213
+ return { ok: false, detail: "no Wi-Fi interface found (networksetup -listallhardwareports)" };
214
+ }
215
+ const rule = sudoersRule({ user: d.user, ...(o.ssid !== undefined ? { ssid: o.ssid, wifiDevice: wifiDevice } : {}) });
216
+ const removeInsomnia = insomniaPresent(d.home, d.exists);
217
+ const temp = d.writeTemp(rule);
218
+ let r;
219
+ try {
220
+ const script = installScript({ tempFile: temp, sha256: crypto.createHash("sha256").update(rule).digest("hex"), removeInsomnia });
221
+ d.print("macOS will ask for your password once, in its own dialog (ClaudeRipple never sees it).");
222
+ r = await d.run(OSASCRIPT, adminOsascriptArgs(script, "ClaudeRipple wants to keep your Mac awake with the lid closed and join your phone hotspot when Wi-Fi fails."), { timeoutMs: 5 * 60_000 });
223
+ }
224
+ finally {
225
+ d.removeTemp(temp);
226
+ }
227
+ if (r.code !== 0) {
228
+ const text = (r.stderr || r.stdout).trim();
229
+ return { ok: false, detail: /-128|cancel/i.test(text) ? "cancelled; nothing was changed" : `the privileged step failed: ${text.slice(0, 300)}` };
230
+ }
231
+ const check = await d.run(SUDO, ["-n", "-l", PMSET, "-a", "disablesleep", "1"]);
232
+ if (check.code !== 0)
233
+ return { ok: false, detail: "the rule was installed but `sudo -n -l` does not allow pmset; check /etc/sudoers.d/clauderipple" };
234
+ d.print(`✓ installed ${SUDOERS_FILE}${o.ssid !== undefined ? ` (pmset, and joining "${o.ssid}" on ${wifiDevice})` : " (pmset)"}`);
235
+ if (removeInsomnia) {
236
+ d.print("✓ cleared Insomnia's sleep setting and its sudoers rule");
237
+ for (const step of insomniaCleanupSteps({ uid: d.uid, home: d.home, exists: d.exists }))
238
+ d.print(`${(await runCleanupStep(step, d)) ? "✓" : "·"} ${step.label}`);
239
+ }
240
+ return { ok: true, detail: "setup complete", ...(wifiDevice ? { wifiDevice } : {}) };
241
+ }
242
+ async function runCleanupStep(step, d) {
243
+ if (step.kind === "run")
244
+ return (await d.run(step.file, step.args)).code === 0;
245
+ const r = await d.run("/usr/bin/trash", [step.path]);
246
+ if (r.code === 0)
247
+ return true;
248
+ try {
249
+ d.trashFallback(step.path);
250
+ return true;
251
+ }
252
+ catch {
253
+ return false;
254
+ }
255
+ }
256
+ export async function awakeRemove(d) {
257
+ if (d.platform === "win32") {
258
+ // Nothing was installed; put back a lid action a router left changed.
259
+ const r = await releaseAwake(d.run, d.rippleHome, "awake setup --remove", "win32");
260
+ if (r === "failed")
261
+ return { ok: false, detail: `could not restore the lid-close action: ${releaseFailedHint("win32")}` };
262
+ return { ok: true, detail: `nothing to remove on Windows${r === "released" ? "; the lid-close action is back to yours" : ""}` };
263
+ }
264
+ if (d.platform !== "darwin")
265
+ return { ok: false, detail: "lid-closed awake is macOS and Windows only" };
266
+ const journal = readJournal(path.join(d.rippleHome, "awake.json"));
267
+ const script = removeScript({ clearSleepBit: journal?.applied === true });
268
+ d.print("macOS will ask for your password once, in its own dialog.");
269
+ const r = await d.run(OSASCRIPT, adminOsascriptArgs(script, "ClaudeRipple wants to remove its lid-closed awake permission."), { timeoutMs: 5 * 60_000 });
270
+ if (r.code !== 0) {
271
+ const text = (r.stderr || r.stdout).trim();
272
+ return { ok: false, detail: /-128|cancel/i.test(text) ? "cancelled; nothing was changed" : `the privileged step failed: ${text.slice(0, 300)}` };
273
+ }
274
+ if (journal?.applied)
275
+ writeReleasedJournal(d.rippleHome, "awake setup --remove");
276
+ return { ok: true, detail: `removed ${SUDOERS_FILE}` };
277
+ }
278
+ function writeReleasedJournal(rippleHome, reason) {
279
+ const file = path.join(rippleHome, "awake.json");
280
+ const prev = readJournal(file);
281
+ fs.writeFileSync(file, JSON.stringify({ applied: false, since: new Date().toISOString(), reason, ...(prev?.lastActivityAt !== undefined ? { lastActivityAt: prev.lastActivityAt } : {}) }) + "\n", { mode: 0o600 });
282
+ }
283
+ /** What to tell a user whose machine could not be released. */
284
+ export function releaseFailedHint(platform = process.platform) {
285
+ return platform === "win32"
286
+ ? "set it back in Control Panel → Power Options → Choose what closing the lid does"
287
+ : "run `sudo pmset -a disablesleep 0`";
288
+ }
289
+ /**
290
+ * If the router set the sleep bit (journal), clear it now with `sudo -n`; on Windows, put back the
291
+ * lid-close action it saved (the execution state died with the router's child). For `stop`,
292
+ * `uninstall` and `awake off` with no router answering: a router that is gone cannot reconcile it.
293
+ */
294
+ export async function releaseAwake(run, rippleHome, reason, platform = process.platform) {
295
+ if (platform === "win32") {
296
+ const journal = readJournal(path.join(rippleHome, "awake.json"));
297
+ const r = await restoreLidFromJournal(run, journal);
298
+ if (r === "failed")
299
+ return r;
300
+ if (journal?.applied)
301
+ writeReleasedJournal(rippleHome, reason);
302
+ return r;
303
+ }
304
+ if (platform !== "darwin")
305
+ return "not-held";
306
+ const journal = readJournal(path.join(rippleHome, "awake.json"));
307
+ if (!journal?.applied)
308
+ return "not-held";
309
+ const r = await run(SUDO, ["-n", PMSET, "-a", "disablesleep", "0"]);
310
+ if (r.code !== 0)
311
+ return "failed";
312
+ writeReleasedJournal(rippleHome, reason);
313
+ return "released";
314
+ }
@@ -12,6 +12,14 @@ export function bundlePath(home) {
12
12
  export function launcherPath(home) {
13
13
  return path.join(bundlePath(home), "Contents", "MacOS", "ClaudeRipple");
14
14
  }
15
+ /** The launcher's text; `repair` compares an existing one against it. */
16
+ export function launcherScript(opts) {
17
+ return `#!/bin/sh
18
+ # ClaudeRipple router launcher (generated by \`clauderipple install\`).
19
+ export CLAUDERIPPLE_HOME="${opts.home}"
20
+ exec "${opts.node}" "${opts.script}"
21
+ `;
22
+ }
15
23
  export function writeBundle(opts) {
16
24
  const app = bundlePath(opts.home);
17
25
  const macos = path.join(app, "Contents", "MacOS");
@@ -33,11 +41,7 @@ export function writeBundle(opts) {
33
41
  </plist>
34
42
  `);
35
43
  const launcher = launcherPath(opts.home);
36
- fs.writeFileSync(launcher, `#!/bin/sh
37
- # ClaudeRipple router launcher (generated by \`clauderipple install\`).
38
- export CLAUDERIPPLE_HOME="${opts.home}"
39
- exec "${opts.node}" "${opts.script}"
40
- `, { mode: 0o755 });
44
+ fs.writeFileSync(launcher, launcherScript(opts), { mode: 0o755 });
41
45
  try {
42
46
  execFileSync("codesign", ["--force", "--sign", "-", "--identifier", BUNDLE_ID, app], { stdio: "ignore" });
43
47
  }
@@ -0,0 +1,159 @@
1
+ // `clauderipple direct on|off` — "use Claude's default connection" and back.
2
+ //
3
+ // on: takes Claude Code (settings.json env) and Claude Desktop (our Config Library entry) off the
4
+ // router, removing only values that are ours, and keeps what it removed in <home>/direct.json.
5
+ // The router, CA trust, agent files and the status mod are left alone, so `off` is instant.
6
+ // off: puts back exactly what direct.json kept. A proxy someone set in the meantime is refused
7
+ // unless forced, and a router that is not answering is repaired first: pointing Claude at a
8
+ // dead router is the failure this command exists to get out of.
9
+ //
10
+ // Both need Claude restarted: Desktop reads its proxy at start, Claude Code sessions at start.
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { ConfigStore, adminPort } from "../../router/src/config.js";
14
+ import { certPaths } from "./certs.js";
15
+ import { classifyMode, cliProxyState, desktopProxyState, fetchRouterStatus, proxyUrlFor, repair } from "./doctor.js";
16
+ import { applyAppProxy, currentAppProxy, removeAppProxy } from "./picker.js";
17
+ import { MODEL_SLOT_ENV, editSettingsEnv, settingsEnv } from "./settings.js";
18
+ export function directStatePath(home) {
19
+ return path.join(home, "direct.json");
20
+ }
21
+ function readState(home) {
22
+ try {
23
+ const s = JSON.parse(fs.readFileSync(directStatePath(home), "utf8"));
24
+ return s && typeof s.cli === "object" ? s : null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ function config(home) {
31
+ return new ConfigStore(path.join(home, "config.json")).get();
32
+ }
33
+ /** A model name Claude answers itself. Anything else in a slot only works through the router. */
34
+ const CLAUDE_MODEL = /claude|opus|sonnet|haiku|fable|mythos|^default$/i;
35
+ function currentMode(home) {
36
+ const cfg = config(home);
37
+ const proxyUrl = proxyUrlFor(cfg.listen.port);
38
+ const cli = cliProxyState(settingsEnv(), proxyUrl, certPaths(home).caPem);
39
+ const desktop = desktopProxyState(currentAppProxy(), proxyUrl);
40
+ return classifyMode(cli.proxy, desktop.proxy, cfg.picker?.enabled === true);
41
+ }
42
+ export function directOn(home) {
43
+ const cfg = config(home);
44
+ const proxyUrl = proxyUrlFor(cfg.listen.port);
45
+ const caPath = certPaths(home).caPem;
46
+ const removedCli = {};
47
+ const notes = [];
48
+ const edit = editSettingsEnv((env) => {
49
+ const proxy = cliProxyState(env, proxyUrl, caPath);
50
+ if (proxy.proxy === "ripple" || proxy.stale) {
51
+ removedCli.HTTPS_PROXY = env.HTTPS_PROXY;
52
+ delete env.HTTPS_PROXY;
53
+ }
54
+ else if (env.HTTPS_PROXY)
55
+ notes.push(`left HTTPS_PROXY=${env.HTTPS_PROXY} (not ClaudeRipple's)`);
56
+ if (env.NODE_EXTRA_CA_CERTS === caPath) {
57
+ removedCli.NODE_EXTRA_CA_CERTS = env.NODE_EXTRA_CA_CERTS;
58
+ delete env.NODE_EXTRA_CA_CERTS;
59
+ }
60
+ // A slot naming a routed model makes every request on it fail once the router is out of the
61
+ // path (Anthropic answers 404), so those go too — but only when Claude Code is now direct.
62
+ if (!env.HTTPS_PROXY) {
63
+ for (const [slot, name] of Object.entries(MODEL_SLOT_ENV)) {
64
+ const value = env[name];
65
+ if (value === undefined)
66
+ continue;
67
+ const ours = value === cfg.cli.models?.[slot] || !CLAUDE_MODEL.test(value);
68
+ if (!ours)
69
+ continue;
70
+ removedCli[name] = value;
71
+ delete env[name];
72
+ }
73
+ }
74
+ return Object.keys(removedCli).length > 0;
75
+ });
76
+ let desktop = null;
77
+ const ap = currentAppProxy();
78
+ if (ap.ours && ap.egressProxyUrl) {
79
+ removeAppProxy();
80
+ desktop = { egressProxyUrl: ap.egressProxyUrl, appliedAfter: currentAppProxy().appliedId };
81
+ }
82
+ else if (ap.egressProxyUrl === proxyUrl)
83
+ notes.push(`Claude Desktop's applied configuration ${ap.appliedId} points at ClaudeRipple but was not written by it; left as it is`);
84
+ const changed = edit.changed || desktop !== null;
85
+ if (changed) {
86
+ // Merged with an earlier direct.json: running `direct on` twice must not forget the first.
87
+ const previous = readState(home);
88
+ const state = { at: new Date().toISOString(), cli: { ...(previous?.cli ?? {}), ...removedCli }, desktop: desktop ?? previous?.desktop ?? null };
89
+ fs.mkdirSync(home, { recursive: true });
90
+ fs.writeFileSync(directStatePath(home), JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
91
+ }
92
+ const removed = [
93
+ ...Object.keys(removedCli).map((k) => `settings.json env.${k}`),
94
+ ...(desktop ? ["Claude Desktop's proxy entry"] : []),
95
+ ];
96
+ const detail = changed
97
+ ? `Claude now connects directly. Removed: ${removed.join(", ")}. Saved in ${directStatePath(home)}.${edit.backup ? ` settings.json backup: ${edit.backup}.` : ""}`
98
+ : "Claude was already on its default connection; nothing of ClaudeRipple's was in its settings.";
99
+ return { ok: true, mode: currentMode(home), restartClaude: true, detail: [detail, ...notes].join(" ") };
100
+ }
101
+ export async function directOff(deps, opts = {}) {
102
+ const home = deps.home;
103
+ const cfg = config(home);
104
+ const proxyUrl = proxyUrlFor(cfg.listen.port);
105
+ const saved = readState(home);
106
+ // No direct.json (it was removed, or Claude was taken off by hand): what `install` and
107
+ // `picker on` would have written is the closest thing to "exactly as before".
108
+ const cli = saved?.cli ?? {
109
+ HTTPS_PROXY: proxyUrl,
110
+ NODE_EXTRA_CA_CERTS: certPaths(home).caPem,
111
+ ...Object.fromEntries(Object.entries(MODEL_SLOT_ENV).flatMap(([slot, name]) => {
112
+ const model = cfg.cli.models?.[slot];
113
+ return model ? [[name, model]] : [];
114
+ })),
115
+ };
116
+ const desktopUrl = saved ? saved.desktop?.egressProxyUrl ?? null : cfg.picker?.enabled ? proxyUrl : null;
117
+ const fail = (detail) => ({ ok: false, mode: currentMode(home), restartClaude: true, detail });
118
+ // Something set since `direct on` is someone else's choice, and replacing it needs saying so.
119
+ const env = settingsEnv();
120
+ if (!opts.force && cli.HTTPS_PROXY && env.HTTPS_PROXY && env.HTTPS_PROXY !== cli.HTTPS_PROXY) {
121
+ return fail(`settings.json now sets HTTPS_PROXY=${env.HTTPS_PROXY}, which is not ClaudeRipple's; left everything as it is. Run \`clauderipple direct off --force\` to replace it.`);
122
+ }
123
+ const ap = currentAppProxy();
124
+ const desktopChanged = ap.appliedId !== (saved?.desktop?.appliedAfter ?? ap.appliedId);
125
+ if (!opts.force && desktopUrl && ap.egressProxyUrl && ap.egressProxyUrl !== desktopUrl && (desktopChanged || !saved)) {
126
+ return fail(`Claude Desktop now uses another proxy (${ap.egressProxyUrl}); left everything as it is. Run \`clauderipple direct off --force\` to replace it.`);
127
+ }
128
+ const notes = [];
129
+ if ((await (deps.routerStatus ?? fetchRouterStatus)(`http://127.0.0.1:${adminPort(cfg)}`, 4_000)) === null) {
130
+ const fixed = await repair(deps);
131
+ if (!fixed.doctor.router.responding) {
132
+ const failed = fixed.steps.filter((s) => !s.ok).map((s) => `${s.name}: ${s.detail}`).join("; ");
133
+ return fail(`the router is not answering even after a repair (${failed || "no step failed, it just did not come up"}), so Claude stays on its default connection.`);
134
+ }
135
+ notes.push("The router was not answering; repaired it first.");
136
+ }
137
+ const restoredKeys = [];
138
+ const edit = editSettingsEnv((current) => {
139
+ for (const [k, v] of Object.entries(cli)) {
140
+ if (current[k] !== v) {
141
+ current[k] = v;
142
+ restoredKeys.push(k);
143
+ }
144
+ }
145
+ return restoredKeys.length > 0;
146
+ });
147
+ let desktopApplied = false;
148
+ if (desktopUrl) {
149
+ const now = currentAppProxy();
150
+ if (!(now.ours && now.egressProxyUrl === desktopUrl)) {
151
+ applyAppProxy(desktopUrl);
152
+ desktopApplied = true;
153
+ }
154
+ }
155
+ fs.rmSync(directStatePath(home), { force: true });
156
+ const restored = [...restoredKeys.map((k) => `settings.json env.${k}`), ...(desktopApplied ? ["Claude Desktop's proxy entry"] : [])];
157
+ const detail = restored.length > 0 ? `Claude goes through ClaudeRipple again. Restored: ${restored.join(", ")}.${edit.backup ? ` settings.json backup: ${edit.backup}.` : ""}` : "Claude already went through ClaudeRipple; nothing to restore.";
158
+ return { ok: true, mode: currentMode(home), restartClaude: true, detail: [...notes, detail].join(" ") };
159
+ }