clauderipple 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +229 -0
- package/LICENSE +674 -0
- package/README.ko.md +328 -0
- package/README.md +372 -0
- package/bin/clauderipple.js +12 -0
- package/dist/app/assets/trayDownTemplate.png +0 -0
- package/dist/app/assets/trayDownTemplate@2x.png +0 -0
- package/dist/app/assets/trayTemplate.png +0 -0
- package/dist/app/assets/trayTemplate@2x.png +0 -0
- package/dist/app/assets/trayWarnTemplate.png +0 -0
- package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
- package/dist/app/assets/trayWin.png +0 -0
- package/dist/app/assets/trayWin@2x.png +0 -0
- package/dist/app/assets/trayWinDown.png +0 -0
- package/dist/app/assets/trayWinDown@2x.png +0 -0
- package/dist/app/assets/trayWinWarn.png +0 -0
- package/dist/app/assets/trayWinWarn@2x.png +0 -0
- package/dist/app/dist/main.js +518 -0
- package/dist/cli/src/browser.js +21 -0
- package/dist/cli/src/bundle.js +51 -0
- package/dist/cli/src/certs.js +33 -0
- package/dist/cli/src/claude-auth.js +112 -0
- package/dist/cli/src/codex.js +172 -0
- package/dist/cli/src/gen-certs.js +7 -0
- package/dist/cli/src/hooks/agent-title.js +160 -0
- package/dist/cli/src/index.js +489 -0
- package/dist/cli/src/launchd.js +183 -0
- package/dist/cli/src/picker.js +166 -0
- package/dist/cli/src/probe.js +55 -0
- package/dist/cli/src/runtime.js +62 -0
- package/dist/cli/src/schtasks.js +134 -0
- package/dist/cli/src/settings.js +142 -0
- package/dist/cli/src/supervisor.js +100 -0
- package/dist/cli/src/tray.js +85 -0
- package/dist/router/src/admin.js +945 -0
- package/dist/router/src/bootstrap.js +80 -0
- package/dist/router/src/certs.js +65 -0
- package/dist/router/src/compat.js +172 -0
- package/dist/router/src/config.js +179 -0
- package/dist/router/src/health.js +45 -0
- package/dist/router/src/identity.js +51 -0
- package/dist/router/src/index.js +144 -0
- package/dist/router/src/ingress/models.js +29 -0
- package/dist/router/src/ingress/server.js +400 -0
- package/dist/router/src/ingress/translate.js +457 -0
- package/dist/router/src/log.js +81 -0
- package/dist/router/src/picker.js +74 -0
- package/dist/router/src/presets.js +267 -0
- package/dist/router/src/providers/anthropic-observed.js +88 -0
- package/dist/router/src/providers/anthropic-token-file.js +48 -0
- package/dist/router/src/providers/anthropic.js +203 -0
- package/dist/router/src/providers/chatgpt/auth.js +226 -0
- package/dist/router/src/providers/chatgpt/index.js +274 -0
- package/dist/router/src/providers/chatgpt/sse.js +28 -0
- package/dist/router/src/providers/chatgpt/translate.js +393 -0
- package/dist/router/src/providers/claude-oauth.js +252 -0
- package/dist/router/src/providers/openai/index.js +193 -0
- package/dist/router/src/providers/openai/translate.js +504 -0
- package/dist/router/src/proxy.js +724 -0
- package/dist/router/src/redact.js +43 -0
- package/dist/router/src/requestlog.js +346 -0
- package/dist/router/src/routing.js +113 -0
- package/dist/router/src/version.js +8 -0
- package/dist/router/src/x509.js +203 -0
- package/dist/ui/app.js +1228 -0
- package/dist/ui/i18n.js +95 -0
- package/dist/ui/index.html +104 -0
- package/dist/ui/presets-fallback.js +61 -0
- package/dist/ui/style.css +347 -0
- package/docs/ARCHITECTURE.md +441 -0
- package/package.json +66 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// clauderipple — install / uninstall / status / start / stop / restart / logs / config
|
|
3
|
+
//
|
|
4
|
+
// install: generates the local CA + leaf, writes a starter config, points
|
|
5
|
+
// ~/.claude/settings.json env at the router, registers the launchd agent,
|
|
6
|
+
// then probes the whole chain end to end.
|
|
7
|
+
// uninstall: reverses exactly that. Home dir is kept unless --purge.
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import net from "node:net";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { ConfigStore, DEFAULTS, homeDir, configPath } from "../../router/src/config.js";
|
|
14
|
+
import { adminPort } from "../../router/src/admin.js";
|
|
15
|
+
import { certsExist, certPaths, generateCerts } from "./certs.js";
|
|
16
|
+
import { applyProxyEnv, currentProxyEnv, removeProxyEnv, settingsPath } from "./settings.js";
|
|
17
|
+
import { agentDefinitionPath, agentState, installAgent, isSupported, isWindows, removeAgent, restartAgent, startAgent, stopAgent, supervisorName } from "./supervisor.js";
|
|
18
|
+
import { BUNDLE_ID, removeBundle, writeBundle } from "./bundle.js";
|
|
19
|
+
import { applyAppProxy, caTrusted, currentAppProxy, removeAppProxy, trustCa, untrustCa } from "./picker.js";
|
|
20
|
+
import { runtime } from "./runtime.js";
|
|
21
|
+
import { codexOff, codexOn } from "./codex.js";
|
|
22
|
+
import { ingressModels } from "../../router/src/ingress/models.js";
|
|
23
|
+
import { claudeLogin, claudeLogout, desktopClaudeCodeDirs } from "./claude-auth.js";
|
|
24
|
+
import { openBrowser } from "./browser.js";
|
|
25
|
+
import { installTrayRuntime, startTray } from "./tray.js";
|
|
26
|
+
import { ClaudeOAuthSession } from "../../router/src/providers/claude-oauth.js";
|
|
27
|
+
function setPickerEnabled(enabled) {
|
|
28
|
+
const file = configPath();
|
|
29
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
30
|
+
raw.picker = { ...(raw.picker ?? {}), enabled };
|
|
31
|
+
fs.writeFileSync(file, JSON.stringify(raw, null, 2) + "\n");
|
|
32
|
+
}
|
|
33
|
+
async function pickerOn() {
|
|
34
|
+
const home = homeDir();
|
|
35
|
+
const cfg = new ConfigStore(configPath()).get();
|
|
36
|
+
const caPem = certPaths(home).caPem;
|
|
37
|
+
if (!fs.existsSync(path.join(home, "ca.key")))
|
|
38
|
+
throw new Error("ca.key missing; run `clauderipple install` first");
|
|
39
|
+
console.log("Picker mode makes Claude Desktop's own claude.ai traffic go through ClaudeRipple so the model picker can list your GPT models.");
|
|
40
|
+
console.log(isWindows
|
|
41
|
+
? "Step 1/3: trusting the ClaudeRipple CA for your Windows user account. Windows will show a confirmation dialog with the certificate fingerprint — answer Yes. No administrator rights are needed."
|
|
42
|
+
: "Step 1/3: trusting the ClaudeRipple CA in your login keychain. macOS will ask for your password (ClaudeRipple never sees it).");
|
|
43
|
+
trustCa(caPem);
|
|
44
|
+
if (!caTrusted())
|
|
45
|
+
throw new Error("CA is not trusted; picker mode not enabled");
|
|
46
|
+
console.log(isWindows ? "✓ CA trusted (current user only)" : "✓ CA trusted (login keychain only)");
|
|
47
|
+
const proxyUrl = proxyUrlFor(cfg.listen.port);
|
|
48
|
+
const r = applyAppProxy(proxyUrl);
|
|
49
|
+
console.log(`✓ Claude Desktop config library entry applied (${r.id}${r.replaced ? `, previous entry ${r.replaced} remembered` : ""}): egressProxyUrl=${proxyUrl}`);
|
|
50
|
+
setPickerEnabled(true);
|
|
51
|
+
console.log("✓ picker.enabled = true (the router picks it up live; no restart)");
|
|
52
|
+
console.log("\nStep 3/3 is yours: quit and reopen Claude Desktop. The app reads its proxy setting at start.");
|
|
53
|
+
console.log("Then open the Code tab picker: entries from cli.extraModels should be there. `clauderipple status` shows the last injection.");
|
|
54
|
+
}
|
|
55
|
+
function describeRestart(r) {
|
|
56
|
+
if (r === "drained")
|
|
57
|
+
return "✓ router restarted (in-flight model calls were allowed to finish)";
|
|
58
|
+
if (r === "kickstarted")
|
|
59
|
+
return "✓ router restarted (hard restart: it did not exit on its own in time)";
|
|
60
|
+
return " router not restarted (not installed?) — run `clauderipple install`";
|
|
61
|
+
}
|
|
62
|
+
function pickerOff() {
|
|
63
|
+
const home = homeDir();
|
|
64
|
+
const removed = removeAppProxy();
|
|
65
|
+
console.log(removed ? "✓ Claude Desktop config library entry removed (previous entry restored if there was one)" : "✓ no ClaudeRipple config library entry");
|
|
66
|
+
try {
|
|
67
|
+
setPickerEnabled(false);
|
|
68
|
+
console.log("✓ picker.enabled = false");
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* no config */
|
|
72
|
+
}
|
|
73
|
+
if (isWindows)
|
|
74
|
+
console.log("Removing the CA: Windows will ask you to confirm once more.");
|
|
75
|
+
const store = isWindows ? "your user certificate store" : "the login keychain";
|
|
76
|
+
console.log(untrustCa(certPaths(home).caPem) ? `✓ CA removed from ${store}` : `✓ CA was not in ${store}`);
|
|
77
|
+
console.log("\nQuit and reopen Claude Desktop to apply.");
|
|
78
|
+
}
|
|
79
|
+
import { VERSION } from "../../router/src/version.js";
|
|
80
|
+
import { probe } from "./probe.js";
|
|
81
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
82
|
+
const installedRuntime = runtime();
|
|
83
|
+
const routerScript = installedRuntime.router;
|
|
84
|
+
const args = process.argv.slice(2);
|
|
85
|
+
const cmd = args[0] ?? "help";
|
|
86
|
+
const flag = (name) => args.includes(`--${name}`);
|
|
87
|
+
const opt = (name) => {
|
|
88
|
+
const i = args.findIndex((a) => a === `--${name}` || a === `-${name}`);
|
|
89
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
90
|
+
};
|
|
91
|
+
/** The router needs a moment to come up under launchd; probe a few times before declaring failure. */
|
|
92
|
+
async function probeWithRetry(o, attempts = 16, delayMs = 750) {
|
|
93
|
+
let last = await probe(o);
|
|
94
|
+
for (let i = 1; i < attempts && !last.ok; i++) {
|
|
95
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
96
|
+
last = await probe(o);
|
|
97
|
+
}
|
|
98
|
+
return last;
|
|
99
|
+
}
|
|
100
|
+
function starterConfig(port) {
|
|
101
|
+
return JSON.stringify({
|
|
102
|
+
$docs: "https://github.com/PBJ-2/clauderipple/blob/main/docs/ARCHITECTURE.md",
|
|
103
|
+
listen: { host: "127.0.0.1", port },
|
|
104
|
+
upstream: DEFAULTS.upstream,
|
|
105
|
+
providers: {},
|
|
106
|
+
routes: {},
|
|
107
|
+
direct: [],
|
|
108
|
+
aliases: {},
|
|
109
|
+
effortClamp: DEFAULTS.effortClamp,
|
|
110
|
+
cli: { extraModels: [] },
|
|
111
|
+
health: DEFAULTS.health,
|
|
112
|
+
log: DEFAULTS.log,
|
|
113
|
+
}, null, 2) + "\n";
|
|
114
|
+
}
|
|
115
|
+
function proxyUrlFor(port) {
|
|
116
|
+
return `http://127.0.0.1:${port}`;
|
|
117
|
+
}
|
|
118
|
+
async function install() {
|
|
119
|
+
const home = homeDir();
|
|
120
|
+
const port = Number(opt("port") ?? DEFAULTS.listen.port);
|
|
121
|
+
if (!isSupported)
|
|
122
|
+
throw new Error(`install supports macOS and Windows; on ${process.platform} run the router manually.`);
|
|
123
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
124
|
+
if (!certsExist(home)) {
|
|
125
|
+
generateCerts(home, DEFAULTS.upstream);
|
|
126
|
+
console.log(`✓ certificates generated in ${home} (not installed in any keychain)`);
|
|
127
|
+
}
|
|
128
|
+
else
|
|
129
|
+
console.log("✓ certificates already present");
|
|
130
|
+
if (!fs.existsSync(configPath())) {
|
|
131
|
+
fs.writeFileSync(configPath(), starterConfig(port));
|
|
132
|
+
console.log(`✓ starter config written: ${configPath()}`);
|
|
133
|
+
}
|
|
134
|
+
else
|
|
135
|
+
console.log(`✓ config kept: ${configPath()}`);
|
|
136
|
+
const cfg = new ConfigStore(configPath()).get();
|
|
137
|
+
const proxyUrl = proxyUrlFor(cfg.listen.port);
|
|
138
|
+
const caPath = certPaths(home).caPem;
|
|
139
|
+
const maxCtx = opt("max-context-tokens");
|
|
140
|
+
const edit = applyProxyEnv({ proxyUrl, caPath, force: flag("force"), ...(maxCtx ? { maxContextTokens: Number(maxCtx) } : {}) });
|
|
141
|
+
console.log(edit.changed ? `✓ ${settingsPath()} updated (backup: ${edit.backup ?? "none"})` : `✓ ${settingsPath()} already correct`);
|
|
142
|
+
for (const n of edit.notes)
|
|
143
|
+
console.log(` note: ${n}`);
|
|
144
|
+
// Persist the single execution contract so the GUI, admin API, hooks, and supervisor all use
|
|
145
|
+
// this installation's runtime rather than any Node installation on the user's PATH.
|
|
146
|
+
fs.writeFileSync(path.join(home, "paths.json"), JSON.stringify({
|
|
147
|
+
node: installedRuntime.node,
|
|
148
|
+
env: installedRuntime.env,
|
|
149
|
+
cli: installedRuntime.cli,
|
|
150
|
+
router: installedRuntime.router,
|
|
151
|
+
hookScript: installedRuntime.hookScript,
|
|
152
|
+
repo: installedRuntime.repo,
|
|
153
|
+
}, null, 2) + "\n");
|
|
154
|
+
// Windows registers node + the router script directly (schtasks.ts writes its own .cmd launcher);
|
|
155
|
+
// a macOS source checkout gets a small .app so the agent shows a real name in Login Items.
|
|
156
|
+
if (installedRuntime.packaged || isWindows) {
|
|
157
|
+
const plist = installAgent({
|
|
158
|
+
program: installedRuntime.node,
|
|
159
|
+
args: [installedRuntime.router],
|
|
160
|
+
bundleId: "com.clauderipple.app",
|
|
161
|
+
home,
|
|
162
|
+
env: { ...installedRuntime.env, ...(process.env.CLAUDE_SETTINGS_PATH ? { CLAUDE_SETTINGS_PATH: process.env.CLAUDE_SETTINGS_PATH } : {}) },
|
|
163
|
+
});
|
|
164
|
+
console.log(`✓ ${supervisorName()} registered: ${plist} (shows as "ClaudeRipple" in Login Items)`);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
const launcher = writeBundle({ home, node: installedRuntime.node, script: installedRuntime.router, version: VERSION });
|
|
168
|
+
console.log(`✓ background item bundle written: ${path.dirname(path.dirname(path.dirname(launcher)))} (shows as "ClaudeRipple" in Login Items)`);
|
|
169
|
+
const plist = installAgent({
|
|
170
|
+
program: launcher,
|
|
171
|
+
bundleId: BUNDLE_ID,
|
|
172
|
+
home,
|
|
173
|
+
...(process.env.CLAUDE_SETTINGS_PATH ? { env: { CLAUDE_SETTINGS_PATH: process.env.CLAUDE_SETTINGS_PATH } } : {}),
|
|
174
|
+
});
|
|
175
|
+
console.log(`✓ ${supervisorName()} registered: ${plist}`);
|
|
176
|
+
}
|
|
177
|
+
// launchd starts the agent the moment it is bootstrapped (RunAtLoad); a scheduled task waits for
|
|
178
|
+
// its logon trigger, so the router would only appear after the next sign-in. Start it either way.
|
|
179
|
+
const started = startAgent();
|
|
180
|
+
console.log(started === "failed" ? `✗ could not start the router (${supervisorName()})` : `✓ router ${started === "already-running" ? "already running" : "started"}`);
|
|
181
|
+
const p = await probeWithRetry({ host: cfg.listen.host, port: cfg.listen.port, caPem: caPath, upstream: cfg.upstream });
|
|
182
|
+
console.log(p.ok ? `✓ end-to-end probe passed (${p.detail}, ${p.ms}ms)` : `✗ probe failed: ${p.detail}`);
|
|
183
|
+
if (!p.ok)
|
|
184
|
+
process.exitCode = 1;
|
|
185
|
+
else
|
|
186
|
+
console.log("\nDone. New Claude Desktop Code sessions go through ClaudeRipple. Existing sessions pick up settings.json env changes on their next request.");
|
|
187
|
+
}
|
|
188
|
+
function uninstall() {
|
|
189
|
+
const home = homeDir();
|
|
190
|
+
const cfg = new ConfigStore(configPath()).get();
|
|
191
|
+
// Stop the router before unregistering it. On macOS unloading the agent takes the process with
|
|
192
|
+
// it; on Windows removing the task leaves it running, and a running router holds router.log open,
|
|
193
|
+
// so --purge then fails with EPERM and leaves the home directory behind (measured 2026-09-16 in a
|
|
194
|
+
// Windows 11 arm64 VM).
|
|
195
|
+
if (stopAgent())
|
|
196
|
+
console.log("✓ router stopped");
|
|
197
|
+
const removed = removeAgent();
|
|
198
|
+
console.log(removed ? `✓ ${supervisorName()} removed (${agentDefinitionPath()})` : `✓ no ${supervisorName()} registered`);
|
|
199
|
+
removeBundle(home);
|
|
200
|
+
const edit = removeProxyEnv({ proxyUrl: proxyUrlFor(cfg.listen.port), caPath: certPaths(home).caPem });
|
|
201
|
+
console.log(edit.changed ? `✓ ${settingsPath()} restored (backup: ${edit.backup ?? "none"})` : `✓ ${settingsPath()} had no ClaudeRipple keys`);
|
|
202
|
+
for (const n of edit.notes)
|
|
203
|
+
console.log(` note: ${n}`);
|
|
204
|
+
if (flag("purge")) {
|
|
205
|
+
// Windows releases a file handle a moment after the process holding it exits; one immediate
|
|
206
|
+
// attempt can still lose the race, so the removal is retried briefly before it is reported.
|
|
207
|
+
let lastError = null;
|
|
208
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
209
|
+
try {
|
|
210
|
+
fs.rmSync(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
|
|
211
|
+
lastError = null;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
lastError = e;
|
|
216
|
+
const until = Date.now() + 300;
|
|
217
|
+
while (Date.now() < until) { /* the CLI is synchronous here; a short spin is the whole wait */ }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (lastError)
|
|
221
|
+
console.log(` could not remove ${home}: ${lastError.message}`);
|
|
222
|
+
else
|
|
223
|
+
console.log(`✓ removed ${home}`);
|
|
224
|
+
}
|
|
225
|
+
else
|
|
226
|
+
console.log(` kept ${home} (config, certs, logs). Add --purge to delete it.`);
|
|
227
|
+
}
|
|
228
|
+
async function status() {
|
|
229
|
+
const home = homeDir();
|
|
230
|
+
const cfg = new ConfigStore(configPath()).get();
|
|
231
|
+
const env = currentProxyEnv();
|
|
232
|
+
const proxyUrl = proxyUrlFor(cfg.listen.port);
|
|
233
|
+
const caPath = certPaths(home).caPem;
|
|
234
|
+
const rows = [];
|
|
235
|
+
rows.push(["home", home]);
|
|
236
|
+
rows.push(["config", fs.existsSync(configPath()) ? `${Object.keys(cfg.routes).length} routes, ${Object.keys(cfg.providers).length} providers, direct=${cfg.direct.map((d) => d.prefix).join(",") || "-"}` : "missing"]);
|
|
237
|
+
rows.push(["certs", certsExist(home) ? "present" : "missing"]);
|
|
238
|
+
rows.push(["settings.json", env.HTTPS_PROXY === proxyUrl && env.NODE_EXTRA_CA_CERTS === caPath ? "points at ClaudeRipple" : `HTTPS_PROXY=${env.HTTPS_PROXY ?? "-"} NODE_EXTRA_CA_CERTS=${env.NODE_EXTRA_CA_CERTS ?? "-"}`]);
|
|
239
|
+
rows.push([supervisorName(), agentState()]);
|
|
240
|
+
const ap = currentAppProxy();
|
|
241
|
+
rows.push(["picker mode", cfg.picker?.enabled ? `on · CA ${caTrusted() ? "trusted" : "NOT trusted"} · app proxy ${ap.ours ? ap.egressProxyUrl : "NOT set"}` : `off${ap.ours ? " (app proxy entry still present — run `picker off`)" : ""}`]);
|
|
242
|
+
const p = await probe({ host: cfg.listen.host, port: cfg.listen.port, caPem: caPath, upstream: cfg.upstream });
|
|
243
|
+
rows.push(["probe", `${p.ok ? "ok" : "FAIL"}: ${p.detail} (${p.ms}ms)`]);
|
|
244
|
+
for (const [name, p2] of Object.entries(cfg.providers)) {
|
|
245
|
+
const providerUrl = p2.type === "chatgpt" ? (p2.url ?? "https://chatgpt.com") : p2.type === "anthropic" ? "https://api.anthropic.com" : p2.url;
|
|
246
|
+
const u = new URL(providerUrl);
|
|
247
|
+
rows.push([`provider ${name}`, await tcpCheck(u.hostname, Number(u.port) || (u.protocol === "https:" ? 443 : 80))]);
|
|
248
|
+
}
|
|
249
|
+
rows.push(["claude code cli", cliVersions()]);
|
|
250
|
+
const w = Math.max(...rows.map((r) => r[0].length));
|
|
251
|
+
for (const [k, v] of rows)
|
|
252
|
+
console.log(`${k.padEnd(w)} ${v}`);
|
|
253
|
+
if (!p.ok)
|
|
254
|
+
process.exitCode = 1;
|
|
255
|
+
}
|
|
256
|
+
function tcpCheck(host, port) {
|
|
257
|
+
return new Promise((resolve) => {
|
|
258
|
+
const s = net.connect({ host, port });
|
|
259
|
+
const t = setTimeout(() => {
|
|
260
|
+
s.destroy();
|
|
261
|
+
resolve("timeout");
|
|
262
|
+
}, 2000);
|
|
263
|
+
s.once("connect", () => {
|
|
264
|
+
clearTimeout(t);
|
|
265
|
+
s.destroy();
|
|
266
|
+
resolve(`listening on ${host}:${port}`);
|
|
267
|
+
});
|
|
268
|
+
s.once("error", (e) => {
|
|
269
|
+
clearTimeout(t);
|
|
270
|
+
resolve(`DOWN (${e.code ?? e.message})`);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
function cliVersions() {
|
|
275
|
+
for (const dir of desktopClaudeCodeDirs()) {
|
|
276
|
+
try {
|
|
277
|
+
const v = fs.readdirSync(dir).filter((d) => /^\d+\.\d+\.\d+$/.test(d)).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
278
|
+
if (v.length)
|
|
279
|
+
return `${v[v.length - 1]} (${v.length} versions cached; app auto-updates the CLI)`;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Next root: the app caches under a different AppData directory depending on the platform.
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return "none cached";
|
|
286
|
+
}
|
|
287
|
+
function ui() {
|
|
288
|
+
const cfg = new ConfigStore(configPath()).get();
|
|
289
|
+
const port = adminPort(cfg);
|
|
290
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
291
|
+
if (openBrowser(url))
|
|
292
|
+
console.log(`opened ${url}`);
|
|
293
|
+
else
|
|
294
|
+
console.log(`could not open a browser automatically; open this URL yourself: ${url}`);
|
|
295
|
+
}
|
|
296
|
+
function logs() {
|
|
297
|
+
const file = path.join(homeDir(), "logs", "router.log");
|
|
298
|
+
if (!fs.existsSync(file)) {
|
|
299
|
+
console.log(`no log yet at ${file}`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const n = Number(opt("n") ?? 50);
|
|
303
|
+
const follow = flag("f") || args.includes("-f");
|
|
304
|
+
// No `tail` on Windows, and reimplementing it in Node keeps both platforms on one code path.
|
|
305
|
+
const printLast = () => {
|
|
306
|
+
const lines = fs.readFileSync(file, "utf8").split("\n");
|
|
307
|
+
const tail = lines.slice(Math.max(0, lines.length - n - 1));
|
|
308
|
+
process.stdout.write(tail.join("\n"));
|
|
309
|
+
return fs.statSync(file).size;
|
|
310
|
+
};
|
|
311
|
+
let offset = printLast();
|
|
312
|
+
if (!follow)
|
|
313
|
+
return;
|
|
314
|
+
fs.watchFile(file, { interval: 500 }, () => {
|
|
315
|
+
const size = fs.statSync(file).size;
|
|
316
|
+
if (size < offset)
|
|
317
|
+
offset = 0; // rotated
|
|
318
|
+
if (size === offset)
|
|
319
|
+
return;
|
|
320
|
+
const fd = fs.openSync(file, "r");
|
|
321
|
+
try {
|
|
322
|
+
const buf = Buffer.alloc(size - offset);
|
|
323
|
+
fs.readSync(fd, buf, 0, buf.length, offset);
|
|
324
|
+
process.stdout.write(buf.toString("utf8"));
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
fs.closeSync(fd);
|
|
328
|
+
}
|
|
329
|
+
offset = size;
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
function help() {
|
|
333
|
+
console.log(`clauderipple <command>
|
|
334
|
+
|
|
335
|
+
install [--port N] [--force] [--max-context-tokens N]
|
|
336
|
+
uninstall [--purge]
|
|
337
|
+
status
|
|
338
|
+
start | stop | restart
|
|
339
|
+
logs [-n N] [-f]
|
|
340
|
+
config print the config file path
|
|
341
|
+
ui open the dashboard in your browser
|
|
342
|
+
tray [--install] start the menu-bar / tray app (--install fetches Electron, ~270MB, once)
|
|
343
|
+
login sign in to ChatGPT (opens your browser; tokens stay in the home dir)
|
|
344
|
+
logout forget the ChatGPT login made with "login"
|
|
345
|
+
claude-login connect a Claude subscription in the browser (--setup-token: via \`claude setup-token\`; --manual: paste the code)
|
|
346
|
+
claude-logout remove ClaudeRipple's own Claude subscription credential
|
|
347
|
+
picker on|off show your mapped models by name in the Claude Desktop picker (trusts the CA in your login keychain, routes the app through ClaudeRipple)
|
|
348
|
+
codex on|off add/remove ClaudeRipple's local OpenAI provider and selection profile for Codex CLI
|
|
349
|
+
agent-title on|off|status
|
|
350
|
+
prefix subagent titles with the real model and thinking depth ("Terra·high · …") via a Claude Code hook
|
|
351
|
+
|
|
352
|
+
Home directory: ${homeDir()} (override with CLAUDERIPPLE_HOME)`);
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
switch (cmd) {
|
|
356
|
+
case "install":
|
|
357
|
+
await install();
|
|
358
|
+
break;
|
|
359
|
+
case "uninstall":
|
|
360
|
+
uninstall();
|
|
361
|
+
break;
|
|
362
|
+
case "status":
|
|
363
|
+
await status();
|
|
364
|
+
break;
|
|
365
|
+
case "start": {
|
|
366
|
+
const r = startAgent();
|
|
367
|
+
console.log(r === "already-running" ? "already running" : r === "started" ? "started" : "start failed (is it installed?)");
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
case "restart":
|
|
371
|
+
console.log(describeRestart(restartAgent({ onProgress: (m) => console.log(` ${m}`) })));
|
|
372
|
+
break;
|
|
373
|
+
case "stop":
|
|
374
|
+
console.log(stopAgent() ? "stopped (run `clauderipple start` or `install` to bring it back)" : "stop failed (not loaded?)");
|
|
375
|
+
break;
|
|
376
|
+
case "logs":
|
|
377
|
+
logs();
|
|
378
|
+
break;
|
|
379
|
+
case "config":
|
|
380
|
+
console.log(configPath());
|
|
381
|
+
break;
|
|
382
|
+
case "picker": {
|
|
383
|
+
const sub = args[1];
|
|
384
|
+
if (sub === "on")
|
|
385
|
+
await pickerOn();
|
|
386
|
+
else if (sub === "off")
|
|
387
|
+
pickerOff();
|
|
388
|
+
else
|
|
389
|
+
console.log("usage: clauderipple picker on|off");
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
case "codex": {
|
|
393
|
+
const sub = args[1];
|
|
394
|
+
if (sub !== "on" && sub !== "off") {
|
|
395
|
+
console.log("usage: clauderipple codex on|off");
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
const cfg = new ConfigStore(configPath()).get();
|
|
399
|
+
const result = sub === "on" ? codexOn(cfg.listen.openaiPort ?? cfg.listen.port + 2, undefined, ingressModels(cfg)) : codexOff();
|
|
400
|
+
console.log(result.changed ? `✓ Codex ${sub}: ${result.config}${sub === "on" ? `\n✓ profile: ${result.profile}` : ""}` : `✓ Codex already ${sub}`);
|
|
401
|
+
if (result.backup)
|
|
402
|
+
console.log(` backup: ${result.backup}`);
|
|
403
|
+
if (result.profileBackup)
|
|
404
|
+
console.log(` profile backup: ${result.profileBackup}`);
|
|
405
|
+
if (sub === "on") {
|
|
406
|
+
console.log("Run: codex --profile clauderipple -m <mapped-model> \"say ok\"");
|
|
407
|
+
}
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
case "agent-title": {
|
|
411
|
+
const sub = args[1];
|
|
412
|
+
const { setAgentTitleHook, agentTitleHookEnabled } = await import("./settings.js");
|
|
413
|
+
if (sub === "on" || sub === "off") {
|
|
414
|
+
const r = setAgentTitleHook(sub === "on", { node: installedRuntime.node, env: installedRuntime.env, script: installedRuntime.hookScript });
|
|
415
|
+
for (const n of r.notes)
|
|
416
|
+
console.log(`✓ ${n}`);
|
|
417
|
+
if (!r.changed)
|
|
418
|
+
console.log(`✓ already ${sub}`);
|
|
419
|
+
if (r.backup)
|
|
420
|
+
console.log(` backup: ${r.backup}`);
|
|
421
|
+
console.log("Applies to new subagents from the next message on; no restart needed.");
|
|
422
|
+
}
|
|
423
|
+
else
|
|
424
|
+
console.log(agentTitleHookEnabled() ? "on" : "off");
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
case "login": {
|
|
428
|
+
const { login } = await import("../../router/src/providers/chatgpt/auth.js");
|
|
429
|
+
console.log("Opening your browser to sign in to ChatGPT. Sign in there; this window waits up to 5 minutes.");
|
|
430
|
+
const t = await login(homeDir(), (url) => {
|
|
431
|
+
if (!openBrowser(url))
|
|
432
|
+
console.log(`Open this URL manually:\n${url}`);
|
|
433
|
+
});
|
|
434
|
+
console.log(`✓ signed in (account ${t.accountId.slice(0, 8)}…, token valid until ${new Date(t.expiresAt).toLocaleString()}). Stored in ${homeDir()}/chatgpt-auth.json`);
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
case "logout": {
|
|
438
|
+
const { logout } = await import("../../router/src/providers/chatgpt/auth.js");
|
|
439
|
+
console.log(logout(homeDir()) ? "✓ ChatGPT login removed" : "no ChatGPT login stored");
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
case "claude-login": {
|
|
443
|
+
if (flag("setup-token")) {
|
|
444
|
+
console.log("Opening your browser through Claude Code to connect your Claude subscription. This terminal waits for approval.");
|
|
445
|
+
// CLAUDERIPPLE_ASSUME_TTY: the test drives this command through a pipe with a fake `claude`.
|
|
446
|
+
claudeLogin(homeDir(), { interactive: process.stdin.isTTY || process.env.CLAUDERIPPLE_ASSUME_TTY === "1" });
|
|
447
|
+
console.log("✓ Claude subscription connected for native Anthropic ingress");
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
// Our own browser sign-in (PKCE). No terminal interaction unless the loopback port is taken,
|
|
451
|
+
// in which case the code from Anthropic's page is pasted here.
|
|
452
|
+
const session = new ClaudeOAuthSession({ home: homeDir(), manual: flag("manual") });
|
|
453
|
+
const { url, manual } = await session.start();
|
|
454
|
+
console.log(manual ? "Opening your browser to sign in to Claude. Paste the code it shows below." : "Opening your browser to sign in to Claude. This window waits up to 5 minutes.");
|
|
455
|
+
if (!openBrowser(url))
|
|
456
|
+
console.log(`Open this URL manually:\n${url}`);
|
|
457
|
+
if (manual) {
|
|
458
|
+
if (!process.stdin.isTTY)
|
|
459
|
+
throw new Error(`the sign-in callback port ${54545} is in use and there is no terminal to paste the code into; free the port or run this in a terminal`);
|
|
460
|
+
const rl = (await import("node:readline")).createInterface({ input: process.stdin, output: process.stdout });
|
|
461
|
+
const code = await new Promise((resolve) => rl.question("Code: ", resolve));
|
|
462
|
+
rl.close();
|
|
463
|
+
await session.submitCode(code);
|
|
464
|
+
}
|
|
465
|
+
await session.result;
|
|
466
|
+
console.log(`✓ Claude subscription connected. Stored in ${homeDir()}/claude-auth.json; refreshed automatically.`);
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
case "claude-logout":
|
|
470
|
+
console.log(claudeLogout(homeDir()) ? "✓ Claude subscription credential removed" : "no Claude subscription credential stored");
|
|
471
|
+
break;
|
|
472
|
+
case "ui":
|
|
473
|
+
ui();
|
|
474
|
+
break;
|
|
475
|
+
case "tray": {
|
|
476
|
+
const result = args.includes("--install") ? installTrayRuntime() : startTray();
|
|
477
|
+
console.log(result.message);
|
|
478
|
+
if (!result.ok)
|
|
479
|
+
process.exit(1);
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
default:
|
|
483
|
+
help();
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
catch (e) {
|
|
487
|
+
console.error(`error: ${e.message}`);
|
|
488
|
+
process.exit(1);
|
|
489
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// macOS supervisor: a per-user launchd agent that keeps the router alive.
|
|
2
|
+
// KeepAlive + ThrottleInterval 2 means a self-exit (code 75) is followed by a fresh
|
|
3
|
+
// process two seconds later, which is the whole point of the health self-exit.
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
export const LABEL = "com.clauderipple.router";
|
|
9
|
+
/** Test-only override; production always uses com.clauderipple.router. */
|
|
10
|
+
export function launchdLabel() {
|
|
11
|
+
return process.env.CLAUDERIPPLE_LAUNCHD_LABEL ?? LABEL;
|
|
12
|
+
}
|
|
13
|
+
export function plistPath() {
|
|
14
|
+
return path.join(os.homedir(), "Library", "LaunchAgents", `${launchdLabel()}.plist`);
|
|
15
|
+
}
|
|
16
|
+
function esc(s) {
|
|
17
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
18
|
+
}
|
|
19
|
+
export function renderPlist(opts) {
|
|
20
|
+
const args = opts.args ?? [];
|
|
21
|
+
const env = { CLAUDERIPPLE_HOME: opts.home, ...(opts.env ?? {}) };
|
|
22
|
+
const bundle = opts.bundleId
|
|
23
|
+
? ` <key>AssociatedBundleIdentifiers</key>
|
|
24
|
+
<array>
|
|
25
|
+
<string>${esc(opts.bundleId)}</string>
|
|
26
|
+
</array>
|
|
27
|
+
`
|
|
28
|
+
: "";
|
|
29
|
+
const environment = Object.entries(env)
|
|
30
|
+
.map(([key, value]) => ` <key>${esc(key)}</key><string>${esc(value)}</string>`)
|
|
31
|
+
.join("\n");
|
|
32
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
33
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
34
|
+
<plist version="1.0">
|
|
35
|
+
<dict>
|
|
36
|
+
<key>Label</key><string>${launchdLabel()}</string>
|
|
37
|
+
<key>ProgramArguments</key>
|
|
38
|
+
<array>
|
|
39
|
+
<string>${esc(opts.program)}</string>
|
|
40
|
+
${args.map((arg) => ` <string>${esc(arg)}</string>`).join("\n")}
|
|
41
|
+
</array>
|
|
42
|
+
${bundle} <key>EnvironmentVariables</key>
|
|
43
|
+
<dict>
|
|
44
|
+
${environment}
|
|
45
|
+
</dict>
|
|
46
|
+
<key>RunAtLoad</key><true/>
|
|
47
|
+
<key>KeepAlive</key><true/>
|
|
48
|
+
<key>ThrottleInterval</key><integer>2</integer>
|
|
49
|
+
<key>ExitTimeOut</key><integer>120</integer>
|
|
50
|
+
<!-- Interactive, not Background: launchd.plist(5) says Background jobs get their CPU and I/O
|
|
51
|
+
bandwidth throttled, and an unset ProcessType still gets "light resource limits". In picker
|
|
52
|
+
mode Claude Desktop sends *all* of its traffic through this proxy, so until it listens the
|
|
53
|
+
app cannot reach anything at all — its responsiveness depends on us, which is exactly the
|
|
54
|
+
case the man page reserves Interactive for. (2026-09-14: 26s from exec to listening after
|
|
55
|
+
a reboot, with the app showing ERR_PROXY_CONNECTION_FAILED the whole time.) -->
|
|
56
|
+
<key>ProcessType</key><string>Interactive</string>
|
|
57
|
+
<key>StandardOutPath</key><string>${esc(opts.stdoutLog)}</string>
|
|
58
|
+
<key>StandardErrorPath</key><string>${esc(opts.stdoutLog)}</string>
|
|
59
|
+
</dict>
|
|
60
|
+
</plist>
|
|
61
|
+
`;
|
|
62
|
+
}
|
|
63
|
+
function launchctl(args) {
|
|
64
|
+
try {
|
|
65
|
+
const out = execFileSync("launchctl", args, { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
66
|
+
return { ok: true, out };
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
const err = e;
|
|
70
|
+
return { ok: false, out: `${err.stdout?.toString() ?? ""}${err.stderr?.toString() ?? ""}`.trim() };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function domain() {
|
|
74
|
+
return `gui/${process.getuid?.() ?? 501}`;
|
|
75
|
+
}
|
|
76
|
+
export function installAgent(opts) {
|
|
77
|
+
const file = plistPath();
|
|
78
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
79
|
+
const stdoutLog = path.join(opts.home, "logs", "launchd.log");
|
|
80
|
+
fs.mkdirSync(path.dirname(stdoutLog), { recursive: true });
|
|
81
|
+
fs.writeFileSync(file, renderPlist({ ...opts, stdoutLog }));
|
|
82
|
+
launchctl(["bootout", domain(), file]); // ignore result: may not be loaded
|
|
83
|
+
const r = launchctl(["bootstrap", domain(), file]);
|
|
84
|
+
if (!r.ok)
|
|
85
|
+
throw new Error(`launchctl bootstrap failed: ${r.out}`);
|
|
86
|
+
return file;
|
|
87
|
+
}
|
|
88
|
+
export function removeAgent() {
|
|
89
|
+
const file = plistPath();
|
|
90
|
+
if (!fs.existsSync(file))
|
|
91
|
+
return false;
|
|
92
|
+
launchctl(["bootout", domain(), file]);
|
|
93
|
+
fs.unlinkSync(file);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
export function agentState() {
|
|
97
|
+
const r = launchctl(["print", `${domain()}/${launchdLabel()}`]);
|
|
98
|
+
if (!r.ok)
|
|
99
|
+
return "not-loaded";
|
|
100
|
+
return /state = running/.test(r.out) ? "running" : "loaded";
|
|
101
|
+
}
|
|
102
|
+
/** `force` adds -k, which SIGKILLs a running instance ~5s after SIGTERM. Only restart wants that. */
|
|
103
|
+
export function kickstart(opts = {}) {
|
|
104
|
+
const args = ["kickstart", ...(opts.force ? ["-k"] : []), `${domain()}/${launchdLabel()}`];
|
|
105
|
+
return launchctl(args).ok;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Idempotent start. A router that is already up (or still coming up) is left alone: `kickstart -k`
|
|
109
|
+
* used to be the "Start Router" path, so pressing it while launchd was still bringing the router
|
|
110
|
+
* up killed it and started the wait over (observed 2026-09-14: three runs in the four minutes
|
|
111
|
+
* after login). Re-bootstraps the plist when the agent was unloaded by `clauderipple stop`.
|
|
112
|
+
*/
|
|
113
|
+
export function startAgent() {
|
|
114
|
+
if (agentState() === "running")
|
|
115
|
+
return "already-running";
|
|
116
|
+
if (kickstart())
|
|
117
|
+
return "started";
|
|
118
|
+
const file = plistPath();
|
|
119
|
+
if (fs.existsSync(file) && launchctl(["bootstrap", domain(), file]).ok)
|
|
120
|
+
return "started";
|
|
121
|
+
return "failed";
|
|
122
|
+
}
|
|
123
|
+
export function agentPid() {
|
|
124
|
+
const r = launchctl(["print", `${domain()}/${launchdLabel()}`]);
|
|
125
|
+
if (!r.ok)
|
|
126
|
+
return null;
|
|
127
|
+
const m = /^\s*pid = (\d+)/m.exec(r.out);
|
|
128
|
+
return m ? Number(m[1]) : null;
|
|
129
|
+
}
|
|
130
|
+
function sleepSync(ms) {
|
|
131
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Graceful restart. `launchctl kickstart -k` SIGKILLs about 5s after its SIGTERM (measured
|
|
135
|
+
* 2026-09-13: the drain was cut and two in-flight model calls died), so instead we send
|
|
136
|
+
* SIGTERM ourselves, let the router drain (up to 90s), wait for the process to exit, and let
|
|
137
|
+
* launchd's KeepAlive relaunch it. Falls back to kickstart only if there is no pid or the
|
|
138
|
+
* process ignores SIGTERM for longer than the drain budget.
|
|
139
|
+
*/
|
|
140
|
+
export function restartAgent(opts = {}) {
|
|
141
|
+
const pid = agentPid();
|
|
142
|
+
if (pid === null)
|
|
143
|
+
return kickstart({ force: true }) ? "kickstarted" : "failed";
|
|
144
|
+
try {
|
|
145
|
+
process.kill(pid, "SIGTERM");
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return kickstart({ force: true }) ? "kickstarted" : "failed";
|
|
149
|
+
}
|
|
150
|
+
const t0 = Date.now();
|
|
151
|
+
const deadline = t0 + (opts.waitMs ?? 120_000);
|
|
152
|
+
let lastTick = 0;
|
|
153
|
+
let exited = false;
|
|
154
|
+
while (Date.now() < deadline) {
|
|
155
|
+
try {
|
|
156
|
+
process.kill(pid, 0);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
exited = true;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
const s = Math.floor((Date.now() - t0) / 1000);
|
|
163
|
+
if (s >= 5 && s !== lastTick && s % 5 === 0) {
|
|
164
|
+
lastTick = s;
|
|
165
|
+
opts.onProgress?.(`draining… ${s}s (waiting for in-flight model calls)`);
|
|
166
|
+
}
|
|
167
|
+
sleepSync(250);
|
|
168
|
+
}
|
|
169
|
+
if (!exited)
|
|
170
|
+
return kickstart({ force: true }) ? "kickstarted" : "failed";
|
|
171
|
+
// KeepAlive relaunches it; ThrottleInterval is 2s.
|
|
172
|
+
const upBy = Date.now() + 15_000;
|
|
173
|
+
while (Date.now() < upBy) {
|
|
174
|
+
if (agentState() === "running" && agentPid() !== pid)
|
|
175
|
+
return "drained";
|
|
176
|
+
sleepSync(250);
|
|
177
|
+
}
|
|
178
|
+
return kickstart({ force: true }) ? "kickstarted" : "failed";
|
|
179
|
+
}
|
|
180
|
+
export function stopAgent() {
|
|
181
|
+
const file = plistPath();
|
|
182
|
+
return launchctl(["bootout", domain(), file]).ok;
|
|
183
|
+
}
|