clauderipple 0.5.1 → 0.7.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 +45 -0
- package/README.ko.md +33 -4
- package/README.md +42 -4
- package/dist/app/dist/main.js +124 -4
- package/dist/app/package.json +1 -0
- package/dist/cli/src/index.js +110 -1
- package/dist/cli/src/tray.js +21 -7
- package/dist/cli/src/update.js +104 -0
- package/dist/router/src/admin.js +118 -4
- package/dist/router/src/agents.js +28 -3
- package/dist/router/src/config.js +2 -0
- package/dist/router/src/index.js +2 -0
- package/dist/router/src/providers/anthropic-usage.js +98 -0
- package/dist/router/src/providers/chatgpt/index.js +112 -0
- package/dist/router/src/proxy.js +21 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +40 -2
- package/dist/ui/i18n.js +12 -0
- package/dist/ui/index.html +5 -0
- package/dist/ui/style.css +1 -0
- package/docs/ARCHITECTURE.md +15 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 — 2026-09-29
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Worker tools** (`cli.limitWorkerTools`, off by default; a toggle in the dashboard). Generated
|
|
8
|
+
workers get only the read, edit, run, search and skill tools instead of inheriting every MCP
|
|
9
|
+
server and plugin tool of the session. One worker's first request fell from 32,942 to 8,823
|
|
10
|
+
tokens. Such a worker cannot use a browser or the iOS Simulator; keep those in an agent file of
|
|
11
|
+
your own.
|
|
12
|
+
- **Images from the ChatGPT subscription.** `clauderipple image "<prompt>" -o file.png`, with
|
|
13
|
+
`--aspect square|landscape|portrait`, `--transparent`, `--format` and `--ref FILE` for reference
|
|
14
|
+
images. Underneath is `POST /api/image` on the admin port, which answers with the image itself.
|
|
15
|
+
Generated workers are told the `curl` form, so a worker with only Bash can make one. The backend
|
|
16
|
+
chooses resolution and quality; about 30 seconds an image.
|
|
17
|
+
|
|
18
|
+
## 0.6.0 — 2026-09-28
|
|
19
|
+
|
|
20
|
+
Both suggested in #8 by @artisthanbohee-del.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Updates from the tray and the CLI** (#39). The tray checks for a new version at start and
|
|
25
|
+
twice a day and offers it in its menu; **Check for Updates…** asks on demand. In a terminal:
|
|
26
|
+
`clauderipple update` (`--check` only reports). It updates the way ClaudeRipple was
|
|
27
|
+
installed — the install script again, or npm into the same prefix — and restarts the router
|
|
28
|
+
on the new version. The standalone app points to the releases page instead.
|
|
29
|
+
- **Usage of each Claude subscription account** (#40): the five-hour and weekly limits of the
|
|
30
|
+
current Claude login and of every account added in ClaudeRipple, on the Health screen, in the
|
|
31
|
+
Claude provider's Accounts tab and in the tray menu. Read from the same endpoint Claude
|
|
32
|
+
Code's `/usage` uses, so an account that has not answered anything yet still has a number.
|
|
33
|
+
- **The version in the tray's About box.**
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **Re-running the installer now restarts a router still running the old version.** Before,
|
|
38
|
+
the files changed but the router kept the old code until the next login — on Windows and
|
|
39
|
+
Linux always, since an unchanged task or service is not registered again.
|
|
40
|
+
- **The tray survives updates.** Its Electron (~270MB) was fetched into the package folder, and
|
|
41
|
+
every update deleted it; on Windows a running tray could make the update fail. It now lives in
|
|
42
|
+
`~/.clauderipple/tray-runtime`, and the install scripts move an existing copy there instead of
|
|
43
|
+
fetching it again. An install made with plain `npm install -g` fetches it once more with
|
|
44
|
+
`clauderipple tray --install`.
|
|
45
|
+
- **The tray installed from npm reports its own version**, not Electron's: its package manifest
|
|
46
|
+
carried none.
|
|
47
|
+
|
|
3
48
|
## 0.5.1 — 2026-09-28
|
|
4
49
|
|
|
5
50
|
### Fixed
|
package/README.ko.md
CHANGED
|
@@ -144,6 +144,13 @@ ClaudeRipple이 Claude에 하는 일은 작고 확인할 수 있습니다. Claud
|
|
|
144
144
|
Claude 모델이 이름 그대로 뜹니다. Claude Code 로그인이나 Anthropic API 키로 답합니다.
|
|
145
145
|
[Codex 앱과 Codex CLI](#codex-앱과-codex-cli) 절을 보세요.
|
|
146
146
|
|
|
147
|
+
### 이미지도 만들 수 있나요?
|
|
148
|
+
|
|
149
|
+
됩니다. API 키 없이 ChatGPT 구독으로 만듭니다: `clauderipple image "빨간 종이배" -o boat.png --aspect square`.
|
|
150
|
+
`--ref 파일`로 참고 이미지를, `--transparent`로 투명 배경을 줄 수 있습니다. 한 장에 30초쯤 걸리고, 해상도와 품질은
|
|
151
|
+
구독 쪽이 정합니다. ClaudeRipple이 만든 서브에이전트에도 같은 방법이 적혀 있어 워커도 이미지를 만듭니다.
|
|
152
|
+
[Claude Code](#claude-code-터미널-remote-control-서브에이전트) 절을 보세요.
|
|
153
|
+
|
|
147
154
|
### Claude Code 안에서는 GPT 성능이 떨어지지 않나요?
|
|
148
155
|
|
|
149
156
|
Claude Code의 시스템 프롬프트와 도구 설명은 Claude에 맞춰 쓰여 있어서, 다른 모델은 남에게 맞춘 지시를 읽는 셈이고
|
|
@@ -219,6 +226,10 @@ ClaudeRipple은 Claude Code 프로세스만 신뢰하는 작은 HTTPS 프록시
|
|
|
219
226
|
서버 측 스레드 처리, 도구 호출과 이미지 왕복, 모델이 받는 범위로 추론 강도 클램프, 호환 벤더에는 Anthropic 전용 필드 제거.
|
|
220
227
|
- **제대로 된 요청 로그.** 누가 물었고 어떤 모델이 답했는지, 입력·캐시·출력 토큰, 지연, 상태를 요청마다. 한 시간 요약 포함.
|
|
221
228
|
- **서브에이전트 이름표.** 백그라운드 작업 패널에 "Agent" 대신 `Terra·high · Review`가 보입니다.
|
|
229
|
+
- **ChatGPT 구독으로 이미지 생성.** `clauderipple image "<프롬프트>"`. 참고 이미지와 투명 배경도 됩니다. 서브에이전트에도
|
|
230
|
+
방법이 적혀 있어, GPT나 DeepSeek 워커가 이미지 도구 없이 이미지를 만들고 직접 확인합니다.
|
|
231
|
+
- **원하면 가벼운 워커.** 스위치 하나로, 생성된 서브에이전트에 세션의 MCP·플러그인 도구 전부 대신 워커가 쓰는 도구만
|
|
232
|
+
줍니다. 워커 첫 요청이 3.3만에서 0.9만 토큰으로 줄었습니다.
|
|
222
233
|
- **터미널이 싫은 사람을 위해.** 원클릭 연결 확인과 모델 자동 검색이 붙은 프로바이더 프리셋, 드롭다운 모델 매핑, 자동 저장,
|
|
223
234
|
한국어·영어 UI. 런타임을 품고 첫 실행에 스스로 설치하는 메뉴 막대 앱. 서명·공증 완료.
|
|
224
235
|
|
|
@@ -280,13 +291,15 @@ clauderipple tray
|
|
|
280
291
|
Electron으로 도는데 용량이 270MB라 기본 설치에는 넣지 않았습니다. `clauderipple tray --install`이 한 번만
|
|
281
292
|
받아 옵니다. 나머지 기능은 Electron 없이도 다 돕니다.
|
|
282
293
|
|
|
283
|
-
**업데이트.**
|
|
294
|
+
**업데이트.** 새 버전이 나오면 트레이가 알려 줍니다(**업데이트 확인…**). 터미널에서는:
|
|
284
295
|
|
|
285
296
|
```sh
|
|
286
|
-
|
|
287
|
-
clauderipple restart
|
|
297
|
+
clauderipple update
|
|
288
298
|
```
|
|
289
299
|
|
|
300
|
+
설치한 방식 그대로(설치 스크립트 또는 npm) 업데이트하고, 라우터를 새 버전으로 다시 시작합니다. 트레이용
|
|
301
|
+
Electron은 업데이트해도 그대로 남습니다.
|
|
302
|
+
|
|
290
303
|
선택 사항, Desktop 피커에 실제 이름을 띄우려면: **클라이언트 → Claude Desktop → 모델 피커 → 켜기**. 그다음 Claude
|
|
291
304
|
Desktop을 완전히 종료했다가 다시 엽니다. 로컬 인증서를 사용자 범위로만 신뢰시키는데, 이때 macOS는 로그인 암호를
|
|
292
305
|
묻고 Windows는 지문이 적힌 확인 창을 띄웁니다. Linux는 묻지 않고 NSS 데이터베이스에 등록합니다. ClaudeRipple은
|
|
@@ -308,7 +321,7 @@ node packages/cli/src/index.ts ui # 브라우저에서 로컬 GUI 열기
|
|
|
308
321
|
|
|
309
322
|
`uninstall`은 전부 되돌리고 `~/.claude/settings.json`을 백업에서 복원합니다. 그 밖의 명령: `status`, `start`, `stop`,
|
|
310
323
|
`restart`, `logs -f`, `login`, `logout`, `claude-login`, `claude-logout`, `picker on|off`, `agent-title on|off`,
|
|
311
|
-
`codex on|off`.
|
|
324
|
+
`codex on|off`, `update`, `image "<프롬프트>"`.
|
|
312
325
|
</details>
|
|
313
326
|
|
|
314
327
|
## 클라이언트
|
|
@@ -353,6 +366,22 @@ model: gpt-5.6-sol@medium
|
|
|
353
366
|
알 수 없는 에러로 죽게 두지 않고 이름을 대고 거부합니다(`400 ClaudeRipple: no provider declares "…"`).
|
|
354
367
|
Claude 모델은 언제나 그대로 통과합니다. 생성을 끄려면 `"cli": { "agentFiles": false }`.
|
|
355
368
|
|
|
369
|
+
생성된 워커는 기본적으로 세션의 도구를 전부 물려받아, MCP 서버·플러그인 도구가 워커 요청마다 실립니다.
|
|
370
|
+
`"cli": { "limitWorkerTools": true }`(대시보드의 **워커 도구 제한**)를 켜면 읽기·편집·실행·검색·스킬 도구만
|
|
371
|
+
주어 워커 첫 요청이 약 3.3만에서 0.9만 토큰으로 줄었습니다. 대신 그 워커는 브라우저·시뮬레이터 도구를 못 씁니다.
|
|
372
|
+
그런 도구가 필요한 워커는 직접 만든 에이전트 파일로 두면 되고, 그 파일은 건드리지 않습니다.
|
|
373
|
+
|
|
374
|
+
ChatGPT 프로바이더가 있으면 같은 구독으로 이미지를 만들 수 있습니다.
|
|
375
|
+
|
|
376
|
+
```bash
|
|
377
|
+
clauderipple image "빨간 종이배, 플랫 일러스트" -o boat.png --aspect square
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
`--transparent`, `--format png|jpeg|webp`, `--ref 파일`(참고 이미지, 여러 번 가능)은 선택입니다. 한 장에 30초쯤 걸립니다.
|
|
381
|
+
구독 백엔드가 해상도와 품질을 직접 정하므로, 모양은 `--aspect`로 말로 요청합니다. 이 명령은 관리 포트의
|
|
382
|
+
`POST /api/image`(응답이 이미지 바이트)를 부르는 클라이언트입니다. 생성된 워커에는 그 `curl` 형태가 적혀 있어,
|
|
383
|
+
Bash만 있는 워커도 이미지를 만들고 Read로 확인할 수 있습니다.
|
|
384
|
+
|
|
356
385
|
Claude Code의 `WebSearch`는 작은 모델에 별도 요청을 보냅니다. ChatGPT로 라우팅한 구성에서 그 요청만
|
|
357
386
|
Anthropic 한도를 쓰지 않게 하려면 `config.json`에서 같은 ChatGPT 프로바이더를 검색 백엔드로 지정합니다:
|
|
358
387
|
|
package/README.md
CHANGED
|
@@ -165,6 +165,15 @@ Yes. `clauderipple codex on` adds ClaudeRipple as a provider, and Claude models
|
|
|
165
165
|
the Codex app's and CLI's model list under their own names. They answer through your
|
|
166
166
|
Claude Code login or an Anthropic API key. See [Codex app and Codex CLI](#codex-app-and-codex-cli).
|
|
167
167
|
|
|
168
|
+
### Can it generate images?
|
|
169
|
+
|
|
170
|
+
Yes, with a ChatGPT subscription and no API key:
|
|
171
|
+
`clauderipple image "a red paper boat" -o boat.png --aspect square`. `--ref FILE` adds
|
|
172
|
+
a reference image and `--transparent` a transparent background. One image takes about
|
|
173
|
+
30 seconds, and the subscription chooses the resolution and quality. Subagents
|
|
174
|
+
ClaudeRipple generates are told the same call, so they can make images too. See
|
|
175
|
+
[Claude Code](#claude-code-terminal-remote-control-subagents).
|
|
176
|
+
|
|
168
177
|
### Does GPT do worse inside Claude Code?
|
|
169
178
|
|
|
170
179
|
Claude Code's system prompt and tool descriptions are written for Claude, so another model
|
|
@@ -263,6 +272,12 @@ you map go to your provider, everything else goes to Anthropic byte for byte.
|
|
|
263
272
|
latency, status, per request, with a one-hour summary.
|
|
264
273
|
- **Subagent name tags.** The background-task panel shows `Terra·high · Review`
|
|
265
274
|
instead of a generic "Agent".
|
|
275
|
+
- **Images from your ChatGPT subscription.** `clauderipple image "<prompt>"`, with
|
|
276
|
+
reference images and transparent backgrounds. Subagents are told how, so a GPT or
|
|
277
|
+
DeepSeek worker can make an image and look at it without an image tool.
|
|
278
|
+
- **Lean workers, if you want them.** One switch gives generated subagents only the
|
|
279
|
+
tools a worker uses instead of every MCP server and plugin of the session: one
|
|
280
|
+
worker's first request fell from 33k to 9k tokens.
|
|
266
281
|
- **Made for people who don't want a terminal.** Provider presets with one-click
|
|
267
282
|
connection tests and model discovery, drop-down model mapping, auto-save, Korean
|
|
268
283
|
and English UI. A menu-bar app that carries its own runtime and sets itself up on
|
|
@@ -328,13 +343,15 @@ clauderipple tray
|
|
|
328
343
|
It runs on Electron, which is about 270MB and is therefore not installed by default.
|
|
329
344
|
`clauderipple tray --install` fetches it once; everything else works without it.
|
|
330
345
|
|
|
331
|
-
**Updating.**
|
|
346
|
+
**Updating.** The tray offers a new version when there is one (**Check for Updates…**), or:
|
|
332
347
|
|
|
333
348
|
```sh
|
|
334
|
-
|
|
335
|
-
clauderipple restart
|
|
349
|
+
clauderipple update
|
|
336
350
|
```
|
|
337
351
|
|
|
352
|
+
It updates the way you installed — the install script again, or npm — and restarts the router
|
|
353
|
+
on the new version. The tray's Electron is kept across updates.
|
|
354
|
+
|
|
338
355
|
Optional, for real names in the Desktop picker: **Clients → Claude Desktop → Model
|
|
339
356
|
picker → on**, then quit and reopen Claude Desktop. To trust the local certificate
|
|
340
357
|
for your user only, macOS asks for your login password and Windows shows a
|
|
@@ -358,7 +375,7 @@ node packages/cli/src/index.ts ui # open the local GUI in your browser
|
|
|
358
375
|
`uninstall` reverses everything and restores `~/.claude/settings.json` from a backup.
|
|
359
376
|
Other commands: `status`, `start`, `stop`, `restart`, `logs -f`, `login`, `logout`,
|
|
360
377
|
`claude-login`, `claude-logout`, `picker on|off`, `agent-title on|off`,
|
|
361
|
-
`codex on|off`.
|
|
378
|
+
`codex on|off`, `update`, `image "<prompt>"`.
|
|
362
379
|
</details>
|
|
363
380
|
|
|
364
381
|
## Clients
|
|
@@ -411,6 +428,27 @@ by name (`400 ClaudeRipple: no provider declares "…"`) instead of being sent o
|
|
|
411
428
|
to fail somewhere less legible; native Claude models always pass through.
|
|
412
429
|
Set `"cli": { "agentFiles": false }` to turn generation off.
|
|
413
430
|
|
|
431
|
+
By default a generated worker inherits every tool of the session, so each MCP
|
|
432
|
+
server and plugin rides along on every worker request. `"cli": {
|
|
433
|
+
"limitWorkerTools": true }` (or **Worker tools** in the dashboard) gives it only
|
|
434
|
+
the read, edit, run, search and skill tools, which cut one worker's first
|
|
435
|
+
request from about 33k to 9k tokens. Such a worker has no browser or simulator
|
|
436
|
+
tools. A worker that needs them belongs in an agent file of your own, which is
|
|
437
|
+
never touched.
|
|
438
|
+
|
|
439
|
+
With a ChatGPT provider configured, images come from the same subscription:
|
|
440
|
+
|
|
441
|
+
```bash
|
|
442
|
+
clauderipple image "a red paper boat, flat illustration" -o boat.png --aspect square
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
`--transparent`, `--format png|jpeg|webp` and `--ref FILE` (a reference image,
|
|
446
|
+
repeatable) are optional. One image takes about 30 seconds. The subscription
|
|
447
|
+
backend picks the resolution and quality itself, so the shape is asked for in
|
|
448
|
+
words (`--aspect`). The command is a client of `POST /api/image` on the admin
|
|
449
|
+
port, which answers with the image bytes. Generated workers are told the `curl`
|
|
450
|
+
form of it, so a worker with only Bash can make an image and read it back.
|
|
451
|
+
|
|
414
452
|
Claude Code runs `WebSearch` as a separate small-model request. To keep a
|
|
415
453
|
ChatGPT-routed setup from spending Anthropic quota for that request, select the
|
|
416
454
|
same ChatGPT provider as the search backend in `config.json`:
|
package/dist/app/dist/main.js
CHANGED
|
@@ -73,6 +73,23 @@ const STRINGS = {
|
|
|
73
73
|
setupNeeded: "setup not finished",
|
|
74
74
|
setupNeededDetail: "ClaudeRipple is installed but not set up yet",
|
|
75
75
|
runSetup: "Finish setting up ClaudeRipple…",
|
|
76
|
+
claudeCurrent: "current login",
|
|
77
|
+
claudeUsage: (label, windows) => `Claude ${label} · ${windows}`,
|
|
78
|
+
window5h: "5h",
|
|
79
|
+
windowWeek: "weekly",
|
|
80
|
+
checkUpdates: "Check for Updates…",
|
|
81
|
+
updateAvailableItem: (v) => `Update to ClaudeRipple ${v}…`,
|
|
82
|
+
upToDate: (v) => `ClaudeRipple ${v} is the latest version.`,
|
|
83
|
+
updateAvailable: (latest, current) => `ClaudeRipple ${latest} is available (you have ${current}).`,
|
|
84
|
+
updateDetail: "The router restarts on the new version once requests in flight have finished. The tray reopens by itself.",
|
|
85
|
+
updateNow: "Update",
|
|
86
|
+
later: "Later",
|
|
87
|
+
updating: (v) => `Updating to ClaudeRipple ${v}…`,
|
|
88
|
+
updateDone: (v) => `Updated to ClaudeRipple ${v}`,
|
|
89
|
+
updateFailed: "The update did not finish",
|
|
90
|
+
updateCheckFailed: "Could not check for updates",
|
|
91
|
+
updatePackaged: "This is the standalone app. Download the new version from the releases page.",
|
|
92
|
+
updateCheckout: "This is a source checkout. Update it with git pull.",
|
|
76
93
|
},
|
|
77
94
|
ko: {
|
|
78
95
|
healthy: "정상",
|
|
@@ -132,6 +149,23 @@ const STRINGS = {
|
|
|
132
149
|
setupNeeded: "설정이 끝나지 않았습니다",
|
|
133
150
|
setupNeededDetail: "설치는 됐지만 아직 설정하지 않았습니다",
|
|
134
151
|
runSetup: "ClaudeRipple 설정 마치기…",
|
|
152
|
+
claudeCurrent: "현재 로그인",
|
|
153
|
+
claudeUsage: (label, windows) => `Claude ${label} · ${windows}`,
|
|
154
|
+
window5h: "5시간",
|
|
155
|
+
windowWeek: "주간",
|
|
156
|
+
checkUpdates: "업데이트 확인…",
|
|
157
|
+
updateAvailableItem: (v) => `새 버전 업데이트: ClaudeRipple ${v}…`,
|
|
158
|
+
upToDate: (v) => `최신 버전입니다: ClaudeRipple ${v}`,
|
|
159
|
+
updateAvailable: (latest, current) => `새 버전이 나왔습니다: ClaudeRipple ${latest} (지금 ${current})`,
|
|
160
|
+
updateDetail: "진행 중인 요청이 끝나면 라우터가 새 버전으로 다시 시작합니다. 트레이는 저절로 다시 열립니다.",
|
|
161
|
+
updateNow: "업데이트",
|
|
162
|
+
later: "나중에",
|
|
163
|
+
updating: (v) => `ClaudeRipple ${v} 업데이트 중…`,
|
|
164
|
+
updateDone: (v) => `ClaudeRipple ${v} 업데이트 완료`,
|
|
165
|
+
updateFailed: "업데이트를 마치지 못했습니다",
|
|
166
|
+
updateCheckFailed: "업데이트를 확인하지 못했습니다",
|
|
167
|
+
updatePackaged: "독립 실행 앱입니다. 릴리스 페이지에서 새 버전을 받으세요.",
|
|
168
|
+
updateCheckout: "소스 체크아웃입니다. git pull로 업데이트하세요.",
|
|
135
169
|
},
|
|
136
170
|
};
|
|
137
171
|
let L = STRINGS.en;
|
|
@@ -223,7 +257,8 @@ async function poll() {
|
|
|
223
257
|
last = null;
|
|
224
258
|
lastError = e.message;
|
|
225
259
|
downSince ??= Date.now();
|
|
226
|
-
|
|
260
|
+
// An update restarts the router on purpose; that is not the outage the notification is for.
|
|
261
|
+
if (!downNotified && !updating && Date.now() - downSince >= DOWN_NOTIFY_AFTER_MS) {
|
|
227
262
|
downNotified = true;
|
|
228
263
|
notifyDown();
|
|
229
264
|
}
|
|
@@ -390,13 +425,77 @@ function cliPaths() {
|
|
|
390
425
|
return { node: process.execPath, env: { ELECTRON_RUN_AS_NODE: "1" }, cli };
|
|
391
426
|
}
|
|
392
427
|
function runCli(args) {
|
|
428
|
+
return runCliResult(args).then((r) => r.output);
|
|
429
|
+
}
|
|
430
|
+
function runCliResult(args) {
|
|
393
431
|
const { node, env, cli } = cliPaths();
|
|
394
432
|
return new Promise((resolve) => {
|
|
395
|
-
|
|
396
|
-
|
|
433
|
+
// An update prints npm's and the installer's own lines; the default 1MB buffer is not for that.
|
|
434
|
+
(0, node_child_process_1.execFile)(node, [cli, ...args], { env: { ...process.env, ...env, CLAUDERIPPLE_HOME: home }, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
435
|
+
resolve({ ok: !err, output: `${stdout}${stderr}${err ? `\n${err.message}` : ""}`.trim() });
|
|
397
436
|
});
|
|
398
437
|
});
|
|
399
438
|
}
|
|
439
|
+
let availableUpdate = null;
|
|
440
|
+
let updating = false;
|
|
441
|
+
const UPDATE_CHECK_MS = 12 * 60 * 60 * 1000;
|
|
442
|
+
async function checkForUpdate() {
|
|
443
|
+
const r = await runCliResult(["update", "--check", "--json"]);
|
|
444
|
+
try {
|
|
445
|
+
const check = JSON.parse(r.output.split("\n").pop() ?? "");
|
|
446
|
+
availableUpdate = check.newer ? check : null;
|
|
447
|
+
render();
|
|
448
|
+
return check;
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async function checkUpdatesInteractive() {
|
|
455
|
+
const check = await checkForUpdate();
|
|
456
|
+
if (!check) {
|
|
457
|
+
await electron_1.dialog.showMessageBox({ type: "warning", message: L.updateCheckFailed });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (!check.newer) {
|
|
461
|
+
await electron_1.dialog.showMessageBox({ message: L.upToDate(check.current) });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
await offerUpdate(check);
|
|
465
|
+
}
|
|
466
|
+
async function offerUpdate(check) {
|
|
467
|
+
if (check.kind === "packaged") {
|
|
468
|
+
const choice = await electron_1.dialog.showMessageBox({ message: L.updateAvailable(check.latest ?? "?", check.current), detail: L.updatePackaged, buttons: [L.updateNow, L.later], defaultId: 0, cancelId: 1 });
|
|
469
|
+
if (choice.response === 0)
|
|
470
|
+
void electron_1.shell.openExternal("https://github.com/PBJ-2/clauderipple/releases/latest");
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (check.kind === "checkout") {
|
|
474
|
+
await electron_1.dialog.showMessageBox({ message: L.updateAvailable(check.latest ?? "?", check.current), detail: L.updateCheckout });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const choice = await electron_1.dialog.showMessageBox({ message: L.updateAvailable(check.latest ?? "?", check.current), detail: L.updateDetail, buttons: [L.updateNow, L.later], defaultId: 0, cancelId: 1 });
|
|
478
|
+
if (choice.response !== 0 || updating)
|
|
479
|
+
return;
|
|
480
|
+
updating = true;
|
|
481
|
+
render();
|
|
482
|
+
const version = check.latest ?? "";
|
|
483
|
+
if (electron_1.Notification.isSupported())
|
|
484
|
+
new electron_1.Notification({ title: "ClaudeRipple", body: L.updating(version) }).show();
|
|
485
|
+
const r = await runCliResult(["update"]);
|
|
486
|
+
updating = false;
|
|
487
|
+
if (!r.ok) {
|
|
488
|
+
render();
|
|
489
|
+
await electron_1.dialog.showMessageBox({ type: "warning", message: L.updateFailed, detail: r.output.slice(-4000) });
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
availableUpdate = null;
|
|
493
|
+
if (electron_1.Notification.isSupported())
|
|
494
|
+
new electron_1.Notification({ title: "ClaudeRipple", body: L.updateDone(version) }).show();
|
|
495
|
+
// A new tray from the new files, then this one goes.
|
|
496
|
+
await runCliResult(["tray"]);
|
|
497
|
+
electron_1.app.quit();
|
|
498
|
+
}
|
|
400
499
|
// Offer setup only when there is no working installation at all. A developer who runs the router
|
|
401
500
|
// from a source checkout keeps that; switching to the app's bundled runtime is a menu action.
|
|
402
501
|
function needsSetup() {
|
|
@@ -436,6 +535,14 @@ async function promptForSetup() {
|
|
|
436
535
|
if (choice.response === 0)
|
|
437
536
|
await setup();
|
|
438
537
|
}
|
|
538
|
+
/** "5h 14% · weekly 10%", the same reading the dashboard gives. */
|
|
539
|
+
function windowsText(quota) {
|
|
540
|
+
const limits = quota?.rate_limits;
|
|
541
|
+
return [limits?.primary, limits?.secondary]
|
|
542
|
+
.filter((w) => !!w && typeof w.used_percent === "number")
|
|
543
|
+
.map((w) => `${w.window_minutes === 300 ? L.window5h : L.windowWeek} ${Math.round(w.used_percent)}%`)
|
|
544
|
+
.join(" · ");
|
|
545
|
+
}
|
|
439
546
|
function render() {
|
|
440
547
|
if (!tray)
|
|
441
548
|
return;
|
|
@@ -462,9 +569,18 @@ function render() {
|
|
|
462
569
|
: unconfigured
|
|
463
570
|
? L.setupNeededDetail
|
|
464
571
|
: L.downNotifyTitle;
|
|
572
|
+
// One line per Claude subscription account, current login first (#40).
|
|
573
|
+
const claudeLines = (s?.claude?.accounts ?? [])
|
|
574
|
+
.map((account) => ({ account, windows: windowsText(account.quota) }))
|
|
575
|
+
.filter(({ windows }) => windows)
|
|
576
|
+
.map(({ account, windows }) => ({ label: L.claudeUsage(account.id === "current" ? L.claudeCurrent : account.label, windows), click: () => void openDashboard() }));
|
|
465
577
|
const template = [
|
|
466
578
|
{ label: headline, click: () => void openDashboard() },
|
|
467
579
|
...(detail ? [{ label: detail, click: () => void openDashboard() }] : []),
|
|
580
|
+
...claudeLines,
|
|
581
|
+
...(availableUpdate
|
|
582
|
+
? [{ label: updating ? L.updating(availableUpdate.latest ?? "") : L.updateAvailableItem(availableUpdate.latest ?? ""), enabled: !updating, click: () => void offerUpdate(availableUpdate) }]
|
|
583
|
+
: []),
|
|
468
584
|
{ type: "separator" },
|
|
469
585
|
// Enabled even when the router is down: it is started first, then the page is opened.
|
|
470
586
|
{ label: L.openDashboard, click: () => void openDashboard() },
|
|
@@ -485,7 +601,8 @@ function render() {
|
|
|
485
601
|
: []),
|
|
486
602
|
{ label: L.connectClaudeSubscription, click: async () => void electron_1.dialog.showMessageBox({ message: await runCli(["claude-login"]) }) },
|
|
487
603
|
{ type: "separator" },
|
|
488
|
-
{ label: L.
|
|
604
|
+
{ label: L.checkUpdates, enabled: !updating, click: () => void checkUpdatesInteractive() },
|
|
605
|
+
{ label: L.about, click: () => void electron_1.dialog.showMessageBox({ title: "ClaudeRipple", message: `ClaudeRipple ${electron_1.app.getVersion()}`, detail: L.aboutDetail }) },
|
|
489
606
|
{ label: L.quit, role: "quit" },
|
|
490
607
|
];
|
|
491
608
|
tray.setContextMenu(electron_1.Menu.buildFromTemplate(template));
|
|
@@ -511,6 +628,9 @@ electron_1.app.whenReady().then(() => {
|
|
|
511
628
|
setInterval(() => void poll(), POLL_MS);
|
|
512
629
|
if (needsSetup())
|
|
513
630
|
void promptForSetup();
|
|
631
|
+
// Not at the very start: a tray opened at login shares the first minute with everything else.
|
|
632
|
+
setTimeout(() => void checkForUpdate(), 60_000);
|
|
633
|
+
setInterval(() => void checkForUpdate(), UPDATE_CHECK_MS);
|
|
514
634
|
});
|
|
515
635
|
// A tray-only app owns no windows; without this Electron would quit the moment a dialog closes.
|
|
516
636
|
electron_1.app.on("window-all-closed", () => {
|
package/dist/app/package.json
CHANGED
package/dist/cli/src/index.js
CHANGED
|
@@ -22,7 +22,8 @@ import { codexOff, codexOn } from "./codex.js";
|
|
|
22
22
|
import { ingressModels } from "../../router/src/ingress/models.js";
|
|
23
23
|
import { claudeLogin, claudeLogout, desktopClaudeCodeDirs } from "./claude-auth.js";
|
|
24
24
|
import { openBrowser } from "./browser.js";
|
|
25
|
-
import { installTrayRuntime, startTray } from "./tray.js";
|
|
25
|
+
import { electronPath, installTrayRuntime, startTray } from "./tray.js";
|
|
26
|
+
import { installKind, isNewer, latestVersion, PACKAGE, runUpdate } from "./update.js";
|
|
26
27
|
import { ClaudeOAuthSession } from "../../router/src/providers/claude-oauth.js";
|
|
27
28
|
function setPickerEnabled(enabled) {
|
|
28
29
|
const file = configPath();
|
|
@@ -119,6 +120,55 @@ function starterConfig(port) {
|
|
|
119
120
|
log: DEFAULTS.log,
|
|
120
121
|
}, null, 2) + "\n";
|
|
121
122
|
}
|
|
123
|
+
const IMAGE_USAGE = 'usage: clauderipple image "<prompt>" [-o FILE] [--aspect square|landscape|portrait] [--format png|jpeg|webp] [--transparent] [--ref FILE ...]';
|
|
124
|
+
/**
|
|
125
|
+
* One image through the running router's ChatGPT subscription (`POST /api/image`), written to a
|
|
126
|
+
* file whose path is printed — so a worker with nothing but Bash can make and then look at one.
|
|
127
|
+
*/
|
|
128
|
+
async function image() {
|
|
129
|
+
const prompt = args[1];
|
|
130
|
+
if (!prompt || prompt.startsWith("-"))
|
|
131
|
+
throw new Error(IMAGE_USAGE);
|
|
132
|
+
const images = args.flatMap((a, i) => (a === "--ref" && args[i + 1] ? [args[i + 1]] : [])).map((file) => {
|
|
133
|
+
const ext = path.extname(file).slice(1).toLowerCase();
|
|
134
|
+
return `data:image/${ext === "jpg" ? "jpeg" : ext};base64,${fs.readFileSync(file).toString("base64")}`;
|
|
135
|
+
});
|
|
136
|
+
const format = opt("format") ?? "png";
|
|
137
|
+
const out = path.resolve(opt("o") ?? `image-${new Date().toISOString().replace(/[:.]/g, "-")}.${format === "jpeg" ? "jpg" : format}`);
|
|
138
|
+
const body = {
|
|
139
|
+
prompt,
|
|
140
|
+
format,
|
|
141
|
+
...(opt("aspect") ? { aspect: opt("aspect") } : {}),
|
|
142
|
+
...(flag("transparent") ? { background: "transparent" } : {}),
|
|
143
|
+
...(images.length ? { images } : {}),
|
|
144
|
+
};
|
|
145
|
+
const port = adminPort(new ConfigStore(configPath()).get());
|
|
146
|
+
let res;
|
|
147
|
+
try {
|
|
148
|
+
res = await fetch(`http://127.0.0.1:${port}/api/image`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
throw new Error(`the router is not answering on 127.0.0.1:${port}; start it with: clauderipple start`);
|
|
152
|
+
}
|
|
153
|
+
if (!res.ok) {
|
|
154
|
+
const answer = (await res.json().catch(() => ({})));
|
|
155
|
+
throw new Error(answer.error ?? `HTTP ${res.status}`);
|
|
156
|
+
}
|
|
157
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
158
|
+
fs.writeFileSync(out, Buffer.from(await res.arrayBuffer()));
|
|
159
|
+
console.log(out);
|
|
160
|
+
}
|
|
161
|
+
/** The version the router on this admin port reports, or null when it does not answer. */
|
|
162
|
+
async function runningVersion(port) {
|
|
163
|
+
try {
|
|
164
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/status`, { signal: AbortSignal.timeout(10_000) });
|
|
165
|
+
const version = res.ok ? (await res.json()).version : null;
|
|
166
|
+
return typeof version === "string" ? version : null;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
122
172
|
function proxyUrlFor(port) {
|
|
123
173
|
return `http://127.0.0.1:${port}`;
|
|
124
174
|
}
|
|
@@ -185,6 +235,16 @@ async function install() {
|
|
|
185
235
|
// its logon trigger, so the router would only appear after the next sign-in. Start it either way.
|
|
186
236
|
const started = startAgent();
|
|
187
237
|
console.log(started === "failed" ? `✗ could not start the router (${supervisorName()})` : `✓ router ${started === "already-running" ? "already running" : "started"}`);
|
|
238
|
+
// Running the installer again is how an update arrives, and it replaces files, not the process:
|
|
239
|
+
// a router left alone keeps serving the old code until the next login. Restart one that
|
|
240
|
+
// answers with another version.
|
|
241
|
+
if (started === "already-running") {
|
|
242
|
+
const running = await runningVersion(adminPort(cfg));
|
|
243
|
+
if (running && running !== VERSION) {
|
|
244
|
+
console.log(` the running router is ${running}; restarting it on ${VERSION}`);
|
|
245
|
+
console.log(describeRestart(restartAgent({ onProgress: (m) => console.log(` ${m}`) })));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
188
248
|
const p = await probeWithRetry({ host: cfg.listen.host, port: cfg.listen.port, caPem: caPath, upstream: cfg.upstream });
|
|
189
249
|
console.log(p.ok ? `✓ end-to-end probe passed (${p.detail}, ${p.ms}ms)` : `✗ probe failed: ${p.detail}`);
|
|
190
250
|
if (!p.ok) {
|
|
@@ -342,6 +402,46 @@ function logs() {
|
|
|
342
402
|
offset = size;
|
|
343
403
|
});
|
|
344
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* `update --check [--json]` reports; `update` installs the latest version the way this copy was
|
|
407
|
+
* installed (update.ts) and fetches the tray's Electron again if the old copy had it.
|
|
408
|
+
*/
|
|
409
|
+
async function update() {
|
|
410
|
+
const kind = installKind(installedRuntime);
|
|
411
|
+
// CLAUDERIPPLE_PACKAGE names what to install instead of the registry's latest (a tarball, say).
|
|
412
|
+
const override = process.env.CLAUDERIPPLE_PACKAGE;
|
|
413
|
+
const latest = override ? null : await latestVersion();
|
|
414
|
+
const newer = latest ? isNewer(latest, VERSION) : true;
|
|
415
|
+
if (flag("check")) {
|
|
416
|
+
if (flag("json"))
|
|
417
|
+
console.log(JSON.stringify({ current: VERSION, latest, newer, kind: kind.kind }));
|
|
418
|
+
else
|
|
419
|
+
console.log(newer ? `ClaudeRipple ${latest} is available (this is ${VERSION}). Update with: clauderipple update` : `ClaudeRipple ${VERSION} is the latest version`);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (kind.kind !== "script" && kind.kind !== "npm") {
|
|
423
|
+
process.exitCode = 1;
|
|
424
|
+
console.log(kind.kind === "packaged" ? "This is the standalone app. Download the new version from https://github.com/PBJ-2/clauderipple/releases"
|
|
425
|
+
: kind.kind === "checkout" ? "This is a source checkout. Update it with git pull."
|
|
426
|
+
: `ClaudeRipple is installed at ${kind.root}, which is not a global npm install. Update it the way you installed it.`);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (!newer && !flag("force")) {
|
|
430
|
+
console.log(`✓ ClaudeRipple ${VERSION} is the latest version`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const hadTray = electronPath() !== null;
|
|
434
|
+
const spec = override ?? `${PACKAGE}@${latest}`;
|
|
435
|
+
console.log(`Updating ClaudeRipple ${VERSION} → ${latest ?? spec} (${kind.kind === "script" ? "install script" : "npm"}, ${kind.prefix})…`);
|
|
436
|
+
const result = runUpdate(kind, installedRuntime, spec);
|
|
437
|
+
if (result.ok && hadTray) {
|
|
438
|
+
// The new files decide where Electron lives; ask them, not this process's copy.
|
|
439
|
+
execFileSync(installedRuntime.node, [installedRuntime.cli, "tray", "--install"], { env: { ...process.env, ...installedRuntime.env }, stdio: "inherit" });
|
|
440
|
+
}
|
|
441
|
+
console.log(result.message);
|
|
442
|
+
if (!result.ok)
|
|
443
|
+
process.exitCode = 1;
|
|
444
|
+
}
|
|
345
445
|
function help() {
|
|
346
446
|
console.log(`clauderipple <command>
|
|
347
447
|
|
|
@@ -353,10 +453,13 @@ function help() {
|
|
|
353
453
|
config print the config file path
|
|
354
454
|
ui open the dashboard in your browser
|
|
355
455
|
tray [--install] start the menu-bar / tray app (--install fetches Electron, ~270MB, once)
|
|
456
|
+
update [--check] install the latest version the way this one was installed (--check only reports)
|
|
356
457
|
login add a ChatGPT account (opens your browser; run again to add another — they take turns when one runs out)
|
|
357
458
|
logout forget every ChatGPT account added with "login" (the Codex CLI's own login is left alone)
|
|
358
459
|
claude-login connect a Claude subscription in the browser (--setup-token: via \`claude setup-token\`; --manual: paste the code)
|
|
359
460
|
claude-logout remove every Claude subscription added to ClaudeRipple
|
|
461
|
+
image "<prompt>" [-o FILE] [--aspect square|landscape|portrait] [--transparent] [--ref FILE ...]
|
|
462
|
+
generate an image with your ChatGPT subscription and print the file's path
|
|
360
463
|
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)
|
|
361
464
|
codex on|off add/remove ClaudeRipple's local OpenAI provider and selection profile for Codex CLI
|
|
362
465
|
agent-title on|off|status
|
|
@@ -489,9 +592,15 @@ try {
|
|
|
489
592
|
case "claude-logout":
|
|
490
593
|
console.log(claudeLogout(homeDir()) ? "✓ ClaudeRipple Claude accounts removed" : "no ClaudeRipple Claude accounts stored");
|
|
491
594
|
break;
|
|
595
|
+
case "image":
|
|
596
|
+
await image();
|
|
597
|
+
break;
|
|
492
598
|
case "ui":
|
|
493
599
|
ui();
|
|
494
600
|
break;
|
|
601
|
+
case "update":
|
|
602
|
+
await update();
|
|
603
|
+
break;
|
|
495
604
|
case "tray": {
|
|
496
605
|
const result = args.includes("--install") ? installTrayRuntime() : startTray();
|
|
497
606
|
console.log(result.message);
|
package/dist/cli/src/tray.js
CHANGED
|
@@ -9,21 +9,34 @@ import { createRequire } from "node:module";
|
|
|
9
9
|
import { spawn, spawnSync } from "node:child_process";
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import path from "node:path";
|
|
12
|
+
import { homeDir } from "../../router/src/config.js";
|
|
12
13
|
import { runtime } from "./runtime.js";
|
|
13
14
|
/** Electron version to fetch on request. Kept in step with the one the app is developed against. */
|
|
14
15
|
const ELECTRON_RANGE = "^38.1.0";
|
|
15
|
-
/**
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Where Electron is fetched to: ClaudeRipple's home, not the package. Until 0.6 it went into the
|
|
18
|
+
* installed package's own node_modules, and every update replaced that folder — measured
|
|
19
|
+
* 2026-09-28, `npm install -g` of another version left no trace of it, so the tray was gone after
|
|
20
|
+
* each update, and on Windows a running tray held electron.exe inside the folder being replaced.
|
|
21
|
+
*/
|
|
22
|
+
export function trayRuntimeDir() {
|
|
23
|
+
return path.join(homeDir(), "tray-runtime");
|
|
24
|
+
}
|
|
25
|
+
/** The electron package exports the path to its binary as its module value. */
|
|
26
|
+
function resolveElectron(from) {
|
|
17
27
|
try {
|
|
18
|
-
const
|
|
19
|
-
// The electron package exports the path to its binary as its module value.
|
|
20
|
-
const value = require("electron");
|
|
28
|
+
const value = createRequire(from)("electron");
|
|
21
29
|
return typeof value === "string" && fs.existsSync(value) ? value : null;
|
|
22
30
|
}
|
|
23
31
|
catch {
|
|
24
32
|
return null;
|
|
25
33
|
}
|
|
26
34
|
}
|
|
35
|
+
/** The Electron executable to run the tray with, or null when none has been fetched. */
|
|
36
|
+
export function electronPath() {
|
|
37
|
+
// A checkout or an install from before 0.6 resolves it next to us.
|
|
38
|
+
return resolveElectron(path.join(trayRuntimeDir(), "package.json")) ?? resolveElectron(import.meta.url);
|
|
39
|
+
}
|
|
27
40
|
/**
|
|
28
41
|
* Our environment without ELECTRON_RUN_AS_NODE, so Electron starts as the tray rather than as a
|
|
29
42
|
* plain Node process. The variable has to be removed, not emptied: macOS Electron reads an empty
|
|
@@ -87,8 +100,9 @@ export function installTrayRuntime() {
|
|
|
87
100
|
const cli = npmCli();
|
|
88
101
|
if (!cli)
|
|
89
102
|
return { ok: false, message: `found no npm next to ${process.execPath}; install Electron yourself: npm install electron` };
|
|
90
|
-
const target =
|
|
91
|
-
|
|
103
|
+
const target = trayRuntimeDir();
|
|
104
|
+
fs.mkdirSync(target, { recursive: true });
|
|
105
|
+
const result = spawnSync(process.execPath, [cli, "install", "--prefix", target, "--no-package-lock", "--loglevel", "error", `electron@${ELECTRON_RANGE}`], {
|
|
92
106
|
cwd: target,
|
|
93
107
|
stdio: "inherit",
|
|
94
108
|
});
|