clauderipple 0.4.0 → 0.5.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 CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 — 2026-09-24
4
+
5
+ Linux, and four fixes from the first outside contributions.
6
+
7
+ ### Added
8
+
9
+ - **Linux** (#28, #33, #34, by @grapefruit0205). `install`, `status`, `restart` and
10
+ `uninstall` work where there is a systemd user session: the router runs as
11
+ `~/.config/systemd/user/clauderipple.service`, with no sudo. A health self-exit is
12
+ restarted after two seconds, and a stop waits out the 90-second drain. Picker mode
13
+ trusts the local CA in the NSS database Chromium reads (`~/.pki/nssdb`, your user
14
+ only) and needs `certutil` (`libnss3-tools` / `nss-tools`). Measured by the
15
+ contributor on Ubuntu 24.04 x64 with Claude Desktop 2.2553.13.
16
+
17
+ ### Fixed
18
+
19
+ - **`npm test` no longer rewrites your real `~/.claude/settings.json`** (#16, #26, #30).
20
+ The admin tests isolated the router's home but not the Claude settings path, so a
21
+ test run pointed `NODE_EXTRA_CA_CERTS` at a temp directory it then deleted, and every
22
+ Claude Code session on the machine failed until the file was fixed by hand.
23
+ - **A failed install leaves `settings.json` alone** (#27, #31). The proxy settings are
24
+ now written only after the end-to-end probe passes, and `picker on` checks the
25
+ router is answering before it points Claude Desktop at it.
26
+ - **Anthropic-compatible providers keep the conversation after a tool call** (#29, #32).
27
+ Claude Code's `thread: continue` carries only the new messages; the router stripped
28
+ the field and forwarded that delta, so the model got orphan tool results and no task.
29
+ It is now refused as for the translated providers, and the CLI resends the full turn.
30
+ - **`picker on` and `status` from a PowerShell 7 terminal on Windows** (#35). Windows
31
+ PowerShell inherited PowerShell 7's module path and lost the `Cert:` drive: `picker on`
32
+ stopped at "Cannot find drive 'Cert'" and `status` called a trusted CA untrusted.
33
+ Every `powershell.exe` we start now drops the inherited `PSModulePath`. Reproduced
34
+ and checked in a Windows 11 VM.
35
+
36
+ ## 0.4.1 — 2026-09-24
37
+
38
+ ### Fixed
39
+
40
+ - **The tray starts from an npm install** (#8). After 0.3.1 it launched as
41
+ Electron but died at once with "exports is not defined in ES module scope": the
42
+ tray is CommonJS and the package root says `"type": "module"`. The tray now has
43
+ its own `package.json`. Checked by starting it from the packed package.
44
+
3
45
  ## 0.4.0 — 2026-09-24
4
46
 
5
47
  Several ChatGPT accounts, for Claude Desktop and for Codex.
package/README.ko.md CHANGED
@@ -19,6 +19,7 @@
19
19
  <a href="LICENSE"><img alt="GPL-3.0" src="https://img.shields.io/badge/license-GPL--3.0-blue"></a>
20
20
  <img alt="macOS" src="https://img.shields.io/badge/macOS-arm64%20%7C%20x64-black">
21
21
  <img alt="Windows" src="https://img.shields.io/badge/Windows-arm64%20%7C%20x64-0078D4">
22
+ <img alt="Linux" src="https://img.shields.io/badge/Linux-x64%20(systemd)-FCC624">
22
23
  <img alt="Node" src="https://img.shields.io/badge/node-24%2B-success">
23
24
  <a href="README.md"><img alt="English" src="https://img.shields.io/badge/docs-English-blue"></a>
24
25
  </p>
@@ -73,8 +74,10 @@ Claude에 돈을 내고 있다면 3P는 선택지가 아닙니다. 낸 돈의
73
74
 
74
75
  ### 클로드 데스크톱 앱에서 GPT를 쓸 수 있나요?
75
76
 
76
- 됩니다. ClaudeRipple을 설치하고 Claude 모델 이름 하나를 GPT 모델에 매핑하면, Code 탭이 GPT로 답합니다.
77
- 모델 피커에는 GPT의 실제 이름이 뜹니다. 앱 자체는 고치지도 않고, 다른 모드로 바꾸지도 않습니다.
77
+ 됩니다. **Code 탭**과 그 서브에이전트에서 됩니다. ClaudeRipple을 설치하고 ChatGPT Plus나 Pro 구독으로
78
+ 로그인하면, GPT(챗GPT의 GPT-6 Sol·Luna·Astra, GPT-5.6 Terra 등)가 모델 피커에 실제 이름으로 뜨고, 고른 추론 강도
79
+ 그대로 답합니다. 앱 자체는 고치지도 않고, 다른 모드로 바꾸지도 않습니다. 일반 **채팅** 탭은 Claude 그대로입니다.
80
+ 채팅까지 바꾸려면 앱 전체를 게이트웨이 모드로 돌려야 해서 어떤 도구도 못 합니다([1P냐 3P냐](#1p냐-3p냐--이-프로젝트가-존재하는-이유) 참고).
78
81
 
79
82
  ### Claude Code(터미널)에서 GPT를 쓰려면요?
80
83
 
@@ -109,47 +112,96 @@ ChatGPT 구독으로 GPT를 쓰고, OpenRouter나 직접 API 키로 DeepSeek·Ki
109
112
  ### 윈도우에서도 되나요?
110
113
 
111
114
  됩니다. macOS와 Windows, arm64와 x64 모두. 설치·대시보드·재시작·제거를 Windows 11 arm64에서 실측했고,
112
- x64 런타임은 에뮬레이션으로 확인했습니다.
115
+ x64 런타임은 에뮬레이션으로 확인했습니다. systemd 사용자 세션이 있는 Linux에서도 됩니다. 설치, 라우터를 거친
116
+ Code 탭 요청, 스트리밍 응답 도중의 재시작, 제거를 Ubuntu 24.04 x64와 Claude Desktop 2.2553.13에서
117
+ 실측했습니다. 피커 모드도 됩니다. `cli.extraModels`의 모델이 Code 탭 피커에 Claude 모델과 나란히 뜹니다.
113
118
 
114
119
  ### 제 코드가 다른 데로 가나요?
115
120
 
116
121
  가지 않습니다. 프록시는 내 컴퓨터에서 돕니다. 요청은 설정한 프로바이더로만 가고, 자격증명은
117
122
  홈 디렉터리에만 저장됩니다. [개인정보](#개인정보) 절을 보세요.
118
123
 
124
+ ### Claude 계정이 정지(밴)되지 않나요? 약관 위반 아닌가요?
125
+
126
+ ClaudeRipple이 Claude에 하는 일은 작고 확인할 수 있습니다. Claude로 가는 요청은 Claude Code가 보낸 그대로
127
+ 한 바이트도 바꾸지 않고 나가고, GPT 등 다른 프로바이더로 보낸 요청은 Anthropic에 아예 가지 않습니다. 앱을 고치는
128
+ 방식이 아니라, Claude Code가 회사 네트워크용으로 문서화해 둔 프록시·인증서 설정(`HTTPS_PROXY`,
129
+ `NODE_EXTRA_CA_CERTS` — 공식 문서 [Enterprise network configuration](https://code.claude.com/docs/en/corporate-proxy))을 씁니다. 계정 정지 사례로 알려진 것은 반대 방향, 즉 Claude 구독 로그인을 **다른 프로그램**에서
130
+ 쓰는 경우입니다. ClaudeRipple에도 그런 선택 기능이 하나 있습니다. Codex 안에서 Claude를 구독 로그인으로 쓰는
131
+ 것인데, 이는 Anthropic 약관의 적용을 받습니다. Anthropic API 키로 쓰면 이 문제가 없습니다. Claude 구독 여러 개를
132
+ 돌려 쓰는 것도 같은 약관 아래 각자 판단할 일입니다. 계정을 어떻게 다룰지는 Anthropic만 정할 수 있으니, 여기 적은
133
+ 내용은 보장이 아닙니다.
134
+
135
+ ### ChatGPT 계정을 여러 개 쓸 수 있나요?
136
+
137
+ 됩니다. `clauderipple login`을 할 때마다(또는 대시보드의 **+ ChatGPT 계정 추가**) 계정이 하나씩 늘어납니다.
138
+ 한 계정이 한도에 걸리면 같은 요청을 다음 계정이 이어받고, 한도가 찬 계정은 풀릴 때까지 쉽니다. 대화는 답한 계정에
139
+ 고정되어 프롬프트 캐시를 지킵니다. Claude Desktop Code 탭, Claude Code, Codex 앱·CLI의 GPT에 모두 적용됩니다.
140
+
141
+ ### Codex에서 Claude를 쓸 수 있나요?
142
+
143
+ 됩니다. `clauderipple codex on`을 하면 ClaudeRipple이 프로바이더로 추가되고, Codex 앱과 CLI의 모델 목록에
144
+ Claude 모델이 이름 그대로 뜹니다. Claude Code 로그인이나 Anthropic API 키로 답합니다.
145
+ [Codex 앱과 Codex CLI](#codex-앱과-codex-cli) 절을 보세요.
146
+
147
+ ### Claude Code 안에서는 GPT 성능이 떨어지지 않나요?
148
+
149
+ Claude Code의 시스템 프롬프트와 도구 설명은 Claude에 맞춰 쓰여 있어서, 다른 모델은 남에게 맞춘 지시를 읽는 셈이고
150
+ 자기 전용 앱에서와 조금 다르게 굴 수 있습니다. ClaudeRipple은 모델마다 자기 이름과 추론 강도를 알려 주고, 벤더가
151
+ 거부할 도구 스키마를 고쳐 넘기며, 프로바이더별로 지시문을 덧붙일 수 있게 합니다. 흔한 구성은 Claude를 메인으로 두고
152
+ GPT나 DeepSeek을 서브에이전트로 쓰는 것입니다.
153
+
154
+ ### CC Switch, opencodex, claude-code-router와 뭐가 다른가요?
155
+
156
+ [비교표](#네-개-대신-하나)를 보세요. 요약하면 ClaudeRipple은 앱이 Claude에 로그인된 채로 Claude
157
+ **데스크톱 앱**의 Code 탭에 들어가고, 다른 모델을 피커에 실제 이름으로 보여 줍니다. CC Switch도 데스크톱 앱에
158
+ 들어가지만, 앱의 게이트웨이(3P) 모드를 켜는 방식입니다.
159
+
160
+ ### 무료인가요?
161
+
162
+ 무료입니다. GPL-3.0 오픈소스입니다. 이미 쓰고 있는 구독이나 API 키 비용만 듭니다.
163
+
119
164
  ## 네 개 대신 하나
120
165
 
121
- 비슷한 도구들은 터미널용입니다. Claude Code CLI나 Codex CLI가 다른 모델을 쓰게는 해 주지만, Claude **데스크톱 앱**에는
122
- 닿지 못하고, 다른 모델로 바꾸는 순간 Claude 구독 쪽 기능(claude.ai 채팅, Remote Control, 클라우드 세션)을 잃습니다.
166
+ 비슷한 도구들은 Claude Code CLI나 Codex CLI가 다른 모델을 쓰게는 해 주지만, Claude **데스크톱 앱**에는 닿지 못하거나
167
+ 게이트웨이(3P) 모드로만 닿고, 다른 모델로 바꾸는 순간 Claude 구독 쪽 기능(claude.ai 채팅, Remote Control, 클라우드 세션)을 잃습니다.
123
168
  ClaudeRipple은 데스크톱 앱·터미널·Codex를 메뉴 막대 앱 하나로 다루고, 두 구독을 나란히 쓰며, Claude Code 하네스를
124
169
  그대로 둡니다. 스킬·훅·MCP 서버·`CLAUDE.md`·서브에이전트·claude.ai 커넥터가 전부 살아 있는 채로 두뇌만 바뀝니다.
125
170
 
126
- | | ClaudeRipple | opencodex / openclaude | claude-code-router | Claude Desktop 게이트웨이(3P) 모드 |
127
- |---|---|---|---|---|
128
- | 게이트웨이(3P) 모드 **없이** Claude **Desktop** Code 탭 | ✅ | ✅ ¹ | ❌ | ❌ (정의상 불가) |
129
- | claude.ai 채팅·Remote Control·클라우드 세션·커넥터 유지 | ✅ | ✅ ¹ | ❌ | ❌ |
130
- | Claude 구독과 GPT 구독 나란히 | ✅ | ✅ ¹ | ❌ | ❌ |
131
- | Desktop 피커에 실제 모델 이름 | ✅ | ❌ Claude 이름을 빌려 씀 ¹ | ❌ | 일부 |
132
- | ChatGPT 계정 여러 개, 한도 차면 자동 전환 (Desktop·Codex) | ✅ | ✅ | ❌ | ❌ |
133
- | 터미널 `claude` CLI | ✅ | ✅ | ✅ | ✅ |
134
- | Codex **앱**·Codex CLI → Claude | ✅ | ✅ | ❌ | ❌ |
135
- | 번역 프로바이더의 프롬프트 캐시 | **94~99 %** 실측 | 미측정 | 제각각 | 해당 없음 |
136
- | 서브에이전트를 실제 모델 이름으로 표시 | ✅ | ❌ | ❌ | ❌ |
137
- | 터미널 없이 쓰는 설정 GUI | ✅ | ❌ | ❌ | ❌ |
138
- | 런타임 내장, 서명·공증된 앱 | ✅ | ❌ | ❌ | – |
139
-
140
- ¹ opencodex 기준(openclaude는 확인하지 않음). opencodex는 v2.61.0(2026-09-22)부터 게이트웨이 없이 Code 탭에
141
- 들어갑니다. 다만 모델 선택기는 건드리지 않아서, 다른 모델은 Claude 모델 이름에 연결해(`modelMap`) 그 이름으로 고릅니다.
142
- ClaudeRipple은 선택기에 GPT·DeepSeek 등을 실제 이름으로 띄우고, 고른 추론 강도를 그대로 넘깁니다.
171
+ | | ClaudeRipple | CC Switch (2026-09-24 확인) | opencodex / openclaude (2026-09-16 확인) | claude-code-router | Claude Desktop 게이트웨이(3P) 모드 |
172
+ |---|---|---|---|---|---|
173
+ | 게이트웨이(3P) 모드 **없이** Claude **Desktop** Code 탭 | ✅ | ❌ ² | ❌ ¹ | ❌ | ❌ (정의상 불가) |
174
+ | claude.ai 채팅·Remote Control·클라우드 세션·커넥터 유지 | ✅ | ❌ | ❌ | ❌ | ❌ |
175
+ | Claude 구독과 GPT 구독 나란히 | ✅ | ❌ 한 번에 프로바이더 하나 | ❌ 전부 아니면 전무 | ❌ | ❌ |
176
+ | Desktop 피커에 실제 모델 이름 | ✅ | ❌ `claude-*` 역할 이름 ² | ❌ | ❌ | 일부 |
177
+ | ChatGPT 계정 여러 개, 한도 차면 자동 전환 (Desktop·Codex) | ✅ | ❌ 수동 전환 ³ | ✅ | ❌ | ❌ |
178
+ | 터미널 `claude` CLI | ✅ | ✅ | ✅ | ✅ | ✅ |
179
+ | Codex **앱**·Codex CLI → Claude | ✅ | ✅ | ✅ | ❌ | ❌ |
180
+ | 번역 프로바이더의 프롬프트 캐시 | **94~99 %** 실측 | 미측정 | 미측정 | 제각각 | 해당 없음 |
181
+ | 서브에이전트를 실제 모델 이름으로 표시 | ✅ | ❌ | ❌ | ❌ | ❌ |
182
+ | 터미널 없이 쓰는 설정 GUI | ✅ | ✅ | ❌ | ❌ | ❌ |
183
+ | 런타임 내장, 서명·공증된 앱 | ✅ | ✅ | ❌ | ❌ | – |
184
+
185
+ ¹ opencodex는 README에 Claude Desktop 데모를 걸어 두었지만, 저장소에도 문서 사이트에도 설치 절차가 없었습니다
186
+ (2026-09-16 확인). 당시 데스크톱 앱으로 들어가는 공개된 경로는 공식 게이트웨이 설정, 즉 마지막 칸뿐이었습니다.
187
+
188
+ ² CC Switch는 데스크톱 앱의 서드파티 프로필(`Claude-3p/claude_desktop_config.json`, `"inferenceProvider": "gateway"`)을
189
+ 써서 들어갑니다. 즉 마지막 칸과 같은 경로입니다. 로컬 게이트웨이를 거치면 피커에는 `claude-sonnet-*`·`claude-opus-*`·
190
+ `claude-haiku-*` 역할 이름만 보입니다([CC Switch 매뉴얼](https://github.com/farion1231/cc-switch/blob/main/docs/user-manual/en/2-providers/2.6-claude-desktop.md)).
191
+ CC Switch는 이 표보다 많은 도구(Gemini CLI, OpenCode 등)를 다루고, MCP 서버와 스킬을 도구 사이에 동기화합니다.
192
+
193
+ ³ ChatGPT 계정은 프로바이더 카드마다 하나씩 묶이고, 공식 ChatGPT 카드는 페일오버 대상에서 빠집니다(v3.20.0 릴리스 노트).
143
194
 
144
195
  데스크톱 앱의 "서드파티 추론" 설정은 앱 전체를 다른 모드로 바꿔 버립니다. claude.ai 채팅, Remote Control,
145
- 클라우드 세션을 잃습니다. `ANTHROPIC_BASE_URL`을 바꿔치기하는 도구들은 데스크톱 앱에 아예 닿지 못합니다.
196
+ 클라우드 세션을 잃습니다. `ANTHROPIC_BASE_URL`을 바꿔치기하는 도구들은 데스크톱 앱에 닿지 못하고, 닿는 도구는 바로 그
197
+ 설정을 씁니다.
146
198
  ClaudeRipple은 Claude Code 프로세스만 신뢰하는 작은 HTTPS 프록시입니다. 매핑한 모델 요청만 프로바이더로 가고,
147
199
  나머지는 Anthropic으로 바이트 그대로 지나갑니다.
148
200
 
149
201
  ## 할 수 있는 것
150
202
 
151
- - **Claude Desktop과 Claude Code에서 어떤 모델이든.** ChatGPT Plus/Pro 구독으로 GPT-5.6 Terra / Sol / Luna와
152
- GPT-6 Astra를, 아니면 DeepSeek·Kimi·GLM·MiniMax·Qwen·Grok·Mistral·Groq·Together·Fireworks·OpenRouter(400+ 모델)·
203
+ - **Claude Desktop과 Claude Code에서 어떤 모델이든.** ChatGPT Plus/Pro 구독으로 GPT-6 Sol / Luna / Astra와
204
+ GPT-5.6 Terra / Sol / Luna를(OpenAI가 새 모델을 내면 그날 바로 뜹니다), 아니면 DeepSeek·Kimi·GLM·MiniMax·Qwen·Grok·Mistral·Groq·Together·Fireworks·OpenRouter(400+ 모델)·
153
205
  로컬 Ollama / LM Studio를. 피커에 실제 이름으로 띄우거나, Claude 이름에 매핑해서.
154
206
  - **Codex 앱과 Codex CLI에서 Claude를.** Codex가 프로바이더로 인식하는 로컬 OpenAI 호환 엔드포인트. Claude 모델이
155
207
  Codex 자체 모델 목록에 뜹니다. 현재 Claude Code 로그인이나 Anthropic API 키를 씁니다.
@@ -188,6 +240,17 @@ curl -fsSL https://raw.githubusercontent.com/PBJ-2/clauderipple/main/scripts/ins
188
240
  irm https://raw.githubusercontent.com/PBJ-2/clauderipple/main/scripts/install.ps1 | iex
189
241
  ```
190
242
 
243
+ **Linux** (systemd가 있는 데스크톱 세션)
244
+
245
+ ```sh
246
+ curl -fsSL https://raw.githubusercontent.com/PBJ-2/clauderipple/main/scripts/install.sh | sh
247
+ ```
248
+
249
+ 라우터는 사용자 단위 systemd 서비스로 돕니다. 상태는 `systemctl --user status clauderipple`, 출력은
250
+ `journalctl --user -u clauderipple`로 봅니다. 피커 모드는 Chromium이 읽는 NSS 데이터베이스
251
+ (`~/.pki/nssdb`, 내 사용자에게만, sudo 없이)에 로컬 CA를 등록하므로 `certutil`이 필요합니다.
252
+ `clauderipple picker on` 전에 `sudo apt install libnss3-tools`(Fedora는 `sudo dnf install nss-tools`)를 실행하세요.
253
+
191
254
  준비물은 없습니다. 이미 Node 24 이상이 있으면 그것을 쓰고, 없으면 공식 빌드를 `~/.clauderipple/runtime`에
192
255
  내려받습니다. 내려받은 파일은 nodejs.org가 공개한 체크섬과 대조합니다. 그다음 설치까지 마칩니다. 로컬 인증서,
193
256
  `~/.claude/settings.json` 두 줄, 컴퓨터를 켤 때 함께 뜨는 백그라운드 라우터. 관리자 권한도, 암호 입력도 필요
@@ -226,11 +289,12 @@ clauderipple restart
226
289
 
227
290
  선택 사항, Desktop 피커에 실제 이름을 띄우려면: **클라이언트 → Claude Desktop → 모델 피커 → 켜기**. 그다음 Claude
228
291
  Desktop을 완전히 종료했다가 다시 엽니다. 로컬 인증서를 사용자 범위로만 신뢰시키는데, 이때 macOS는 로그인 암호를
229
- 묻고 Windows는 지문이 적힌 확인 창을 띄웁니다. ClaudeRipple은 암호를 보지 않으며, 양쪽 다 관리자 권한이 필요
230
- 없습니다.
292
+ 묻고 Windows는 지문이 적힌 확인 창을 띄웁니다. Linux는 묻지 않고 NSS 데이터베이스에 등록합니다. ClaudeRipple은
293
+ 암호를 보지 않으며, 어느 플랫폼도 관리자 권한이 필요 없습니다.
231
294
 
232
295
  > **Claude Desktop은 창을 닫아도 종료되지 않습니다.** 그 상태로 다시 열면 이전 인스턴스를 재사용해서 새 설정을
233
- > 읽지 않습니다. 제대로 종료하십시오(macOS는 ⌘Q, Windows는 트레이 아이콘 또는 작업 관리자). 안 그러면 피커가
296
+ > 읽지 않습니다. 제대로 종료하십시오(macOS는 ⌘Q, Windows는 트레이 아이콘 또는 작업 관리자, Linux는 Ctrl+Q 또는
297
+ > 트레이 아이콘. `pgrep -f claude-desktop`이 아무것도 출력하지 않을 때까지). 안 그러면 피커가
234
298
  > 아무 말 없이 그대로입니다.
235
299
 
236
300
  <details>
@@ -325,7 +389,7 @@ Anthropic API 키로 갑니다. 구독 로그인 재사용은 Anthropic 약관
325
389
 
326
390
  | 프로바이더 | 종류 | 인증 | 모델 목록 | 비고 |
327
391
  |---|---|---|---|---|
328
- | ChatGPT 구독 | Codex 백엔드 | 로그인 여러 개, 한도 차면 자동 전환(Codex 로그인도 재사용) | Terra, Sol, Luna, Astra | 추론 강도 low…max(Luna는 ultra), 프롬프트 캐시 94~99 % |
392
+ | ChatGPT 구독 | Codex 백엔드 | 로그인 여러 개, 한도 차면 자동 전환(Codex 로그인도 재사용) | 구독에서 자동으로 읽음(GPT-6 Sol·Luna·Astra, GPT-5.6 …) | 추론 강도 low…max(Luna는 ultra), 프롬프트 캐시 94~99 % |
329
393
  | OpenRouter | Anthropic 호환 | API 키 | 400+, 자동 검색 | 모델별 추론 강도 지원을 API에서 읽음 |
330
394
  | DeepSeek, Kimi, Z.ai GLM, MiniMax, Qwen(국제/중국) | Anthropic 호환 | API 키 | 프리셋 | 벤더 공식 문서로 확인 |
331
395
  | xAI Grok, Mistral, Groq, Together, Fireworks | OpenAI 호환 | API 키 | 자동 검색 | 번역(Chat Completions / Responses) |
@@ -369,6 +433,9 @@ Codex 앱 / CLI ──/v1/responses──▶ ClaudeRipple 입구 ──▶ Claud
369
433
  - **Windows 지원은 이제 막 들어갔습니다(2026-09-14).** 설치·설정·라우터·로그인·피커·실제 GPT 호출까지 x64
370
434
  실기에서 확인했고, 크래시 복구는 arm64에서 확인했습니다. 창은 아직 Windows 기본 제목 표시줄을 씁니다.
371
435
  Windows 빌드는 서명되지 않았습니다(위 설치 항목 참고).
436
+ - **Linux 지원도 이제 막 들어갔습니다(2026-09-24).** 기여자가 Ubuntu 24.04 x64에서 설치·Code 탭 요청·스트리밍
437
+ 도중 재시작·피커 모드·제거를 실측해 보내 주었습니다. systemd 사용자 세션이 필요하고, 다른 배포판과 arm64는
438
+ 확인하지 않았습니다.
372
439
  - ChatGPT, OpenRouter, Codex 안의 Claude는 실제 계정으로 검증했습니다. 나머지 프리셋은 벤더 공식 문서를 따릅니다.
373
440
  - 피커에 추가한 모델은 다음 세션부터 쓸 수 있습니다.
374
441
  - Claude Code와 Codex는 통신 규약을 자주 바꿉니다. 클라이언트 업데이트가 번역을 깨뜨리면 ClaudeRipple이 따라잡을 때까지
package/README.md CHANGED
@@ -20,6 +20,7 @@
20
20
  <a href="LICENSE"><img alt="GPL-3.0" src="https://img.shields.io/badge/license-GPL--3.0-blue"></a>
21
21
  <img alt="macOS" src="https://img.shields.io/badge/macOS-arm64%20%7C%20x64-black">
22
22
  <img alt="Windows" src="https://img.shields.io/badge/Windows-arm64%20%7C%20x64-0078D4">
23
+ <img alt="Linux" src="https://img.shields.io/badge/Linux-x64%20(systemd)-FCC624">
23
24
  <img alt="Node" src="https://img.shields.io/badge/node-24%2B-success">
24
25
  <a href="README.ko.md"><img alt="한국어" src="https://img.shields.io/badge/docs-한국어-red"></a>
25
26
  </p>
@@ -80,9 +81,12 @@ the model.
80
81
 
81
82
  ### Can I use GPT in the Claude Desktop app?
82
83
 
83
- Yes. Install ClaudeRipple, map a Claude model name to a GPT model, and the Code tab
84
- answers with GPT under its real name in the model picker. The app itself is not
85
- modified and not switched into any other mode.
84
+ Yes — in the **Code tab** and its subagents. Install ClaudeRipple, sign in with a
85
+ ChatGPT Plus or Pro subscription, and GPT (GPT-6 Sol, Luna, Astra, GPT-5.6 Terra and
86
+ the rest) shows up under its real name in the model picker, with the reasoning effort
87
+ you pick. The app itself is not modified and not switched into any other mode. The
88
+ general **chat** tab stays Claude: no tool can route it without turning the whole app
89
+ into gateway mode (see [1P, not 3P](#1p-not-3p-the-difference-this-whole-project-exists-for)).
86
90
 
87
91
  ### How do I use GPT in Claude Code?
88
92
 
@@ -122,52 +126,115 @@ through OpenRouter or a direct API key. Claude models keep working as they are.
122
126
  ### Does it work on Windows?
123
127
 
124
128
  Yes, on Windows and macOS, arm64 and x64. Install, dashboard, restart and uninstall are
125
- measured on Windows 11 arm64; the x64 runtime is measured under emulation.
129
+ measured on Windows 11 arm64; the x64 runtime is measured under emulation. Linux works too
130
+ where there is a systemd user session: install, a Code-tab request through the router, a
131
+ restart in the middle of a streaming answer and uninstall are measured on Ubuntu 24.04 x64
132
+ with Claude Desktop 2.2553.13, and so is picker mode: models from `cli.extraModels` show
133
+ in the Code-tab picker next to Claude's own.
126
134
 
127
135
  ### Is my code sent anywhere else?
128
136
 
129
137
  No. The proxy runs on your own machine. Requests go to the provider you configured and
130
138
  nowhere else, and credentials stay in your home directory. See [Privacy](#privacy).
131
139
 
140
+ ### Will my Claude account get banned? Is this against Anthropic's terms?
141
+
142
+ What ClaudeRipple does with Claude is small and checkable: a request that stays on Claude
143
+ leaves your machine byte for byte as Claude Code sent it, and a request routed to GPT or
144
+ another provider never reaches Anthropic at all. It works through the proxy and
145
+ certificate settings Claude Code documents for corporate networks (`HTTPS_PROXY`,
146
+ `NODE_EXTRA_CA_CERTS` — [Enterprise network configuration](https://code.claude.com/docs/en/corporate-proxy)),
147
+ not by patching the app. The reports of banned accounts are about
148
+ the opposite direction — a Claude subscription login used from **another** program.
149
+ ClaudeRipple has one optional feature of that kind: Claude inside Codex using your
150
+ subscription login. That use is subject to Anthropic's terms; an Anthropic API key avoids
151
+ the question. Rotating several Claude subscriptions is likewise your call under those
152
+ terms. Only Anthropic can say how an account is treated, so nothing here is a promise.
153
+
154
+ ### Can I use several ChatGPT accounts?
155
+
156
+ Yes. Each `clauderipple login` (or **+ Add ChatGPT account** in the dashboard) adds one.
157
+ When an account hits its usage limit, the same request moves to the next account and the
158
+ spent one rests until its window resets; a conversation stays on the account that
159
+ answered, to keep its prompt cache. This applies to the Claude Desktop Code tab, Claude
160
+ Code, and GPT in the Codex app and CLI.
161
+
162
+ ### Can I use Claude inside Codex?
163
+
164
+ Yes. `clauderipple codex on` adds ClaudeRipple as a provider, and Claude models appear in
165
+ the Codex app's and CLI's model list under their own names. They answer through your
166
+ Claude Code login or an Anthropic API key. See [Codex app and Codex CLI](#codex-app-and-codex-cli).
167
+
168
+ ### Does GPT do worse inside Claude Code?
169
+
170
+ Claude Code's system prompt and tool descriptions are written for Claude, so another model
171
+ reads instructions tuned for someone else, and it may behave a little differently than in
172
+ its own client. ClaudeRipple tells each model its own name and effort, reshapes tool
173
+ schemas a vendor would reject, and lets you append instructions per provider. A common
174
+ setup is Claude as the main model with GPT or DeepSeek as subagents.
175
+
176
+ ### How is this different from CC Switch, opencodex or claude-code-router?
177
+
178
+ See the [comparison table](#one-tool-instead-of-four). In short: ClaudeRipple reaches
179
+ the Claude **Desktop** Code tab while the app stays signed in to Claude, and shows other
180
+ models in its picker under their real names. CC Switch also reaches the desktop app, but
181
+ by turning on its gateway (3P) mode.
182
+
183
+ ### Is it free?
184
+
185
+ Yes. ClaudeRipple is open source under GPL-3.0. You pay only for the subscriptions or API
186
+ keys you already use.
187
+
132
188
  ## One tool instead of four
133
189
 
134
- Similar tools are built for the terminal. They let the Claude Code CLI or Codex CLI
135
- use other models, but they cannot reach the Claude **desktop app**, and the moment
136
- you switch models you lose the Claude-subscription side (claude.ai chat, Remote
137
- Control, cloud sessions). ClaudeRipple covers the desktop app, the terminal and
190
+ Similar tools let the Claude Code CLI or Codex CLI use other models, but they either
191
+ cannot reach the Claude **desktop app** or reach it only through its gateway (3P) mode,
192
+ and the moment you switch models you lose the Claude-subscription side (claude.ai chat,
193
+ Remote Control, cloud sessions). ClaudeRipple covers the desktop app, the terminal and
138
194
  Codex from one menu-bar app, runs both subscriptions side by side, and keeps the
139
195
  Claude Code harness intact: your skills, hooks, MCP servers, `CLAUDE.md`, subagents
140
196
  and claude.ai connectors keep working while another model does the thinking.
141
197
 
142
- | | ClaudeRipple | opencodex / openclaude | claude-code-router | Claude Desktop gateway (3P) mode |
143
- |---|---|---|---|---|
144
- | Claude **Desktop** Code tab **without** gateway (3P) mode | ✅ | ✅ ¹ | ❌ | ❌ by definition |
145
- | Keeps claude.ai chat, Remote Control, cloud sessions, connectors | ✅ | ✅ ¹ | ❌ | ❌ |
146
- | Claude and GPT subscriptions side by side | ✅ | ✅ ¹ | ❌ | ❌ |
147
- | Real model names in the Desktop picker | ✅ | ❌ borrows Claude names ¹ | ❌ | partial |
148
- | Several ChatGPT accounts, switching when one runs out (Desktop and Codex) | ✅ | ✅ | ❌ | ❌ |
149
- | Terminal `claude` CLI | ✅ | ✅ | ✅ | ✅ |
150
- | Codex **app** and Codex CLI → Claude | ✅ | ✅ | ❌ | ❌ |
151
- | Prompt cache on translated providers | **94–99 %** measured | not measured | varies | n/a |
152
- | Subagents named by real model in the task panel | ✅ | ❌ | ❌ | ❌ |
153
- | Settings GUI, no terminal needed | ✅ | ❌ | ❌ | ❌ |
154
- | Signed, notarized app with its own runtime | ✅ | ❌ | ❌ | – |
155
-
156
- ¹ opencodex (openclaude not checked). Since v2.61.0 (2026-09-22) opencodex reaches the Code
157
- tab without the gateway. It leaves the model picker alone, so another model is chosen under a
158
- Claude model's name it is mapped to (`modelMap`). ClaudeRipple puts GPT, DeepSeek and the rest
159
- in the picker under their own names and passes the effort you pick.
198
+ | | ClaudeRipple | CC Switch (checked 2026-09-24) | opencodex / openclaude (checked 2026-09-16) | claude-code-router | Claude Desktop gateway (3P) mode |
199
+ |---|---|---|---|---|---|
200
+ | Claude **Desktop** Code tab **without** gateway (3P) mode | ✅ | ❌ ² | ❌ ¹ | ❌ | ❌ by definition |
201
+ | Keeps claude.ai chat, Remote Control, cloud sessions, connectors | ✅ | ❌ | ❌ | ❌ | ❌ |
202
+ | Claude and GPT subscriptions side by side | ✅ | ❌ one provider at a time | ❌ all-or-nothing | ❌ | ❌ |
203
+ | Real model names in the Desktop picker | ✅ | ❌ `claude-*` role names ² | ❌ | ❌ | partial |
204
+ | Several ChatGPT accounts, switching when one runs out (Desktop and Codex) | ✅ | ❌ switched by hand ³ | ✅ | ❌ | ❌ |
205
+ | Terminal `claude` CLI | ✅ | ✅ | ✅ | ✅ | ✅ |
206
+ | Codex **app** and Codex CLI → Claude | ✅ | ✅ | ✅ | ❌ | ❌ |
207
+ | Prompt cache on translated providers | **94–99 %** measured | not measured | not measured | varies | n/a |
208
+ | Subagents named by real model in the task panel | ✅ | ❌ | ❌ | ❌ | ❌ |
209
+ | Settings GUI, no terminal needed | ✅ | ✅ | ❌ | ❌ | ❌ |
210
+ | Signed, notarized app with its own runtime | ✅ | ✅ | ❌ | ❌ | – |
211
+
212
+ ¹ opencodex's README showed Claude Desktop in a demo but published no setup steps for it,
213
+ in the repository or on its documentation site (checked 2026-09-16). At the time the only
214
+ public way into the desktop app was the official gateway setting — the last column.
215
+
216
+ ² CC Switch reaches Claude Desktop by writing the app's third-party profile
217
+ (`Claude-3p/claude_desktop_config.json`, `"inferenceProvider": "gateway"`), which is the
218
+ last column. Through its local gateway the picker shows only `claude-sonnet-*`,
219
+ `claude-opus-*` and `claude-haiku-*` role names ([its manual](https://github.com/farion1231/cc-switch/blob/main/docs/user-manual/en/2-providers/2.6-claude-desktop.md)).
220
+ CC Switch manages more tools than this table covers (Gemini CLI, OpenCode and others) and
221
+ syncs MCP servers and skills across them.
222
+
223
+ ³ Each ChatGPT account is bound to its own provider card, and official ChatGPT cards are
224
+ kept out of the failover queue (v3.20.0 release notes).
160
225
 
161
226
  The desktop app's own "third-party inference" setting flips the whole app into another
162
227
  mode: you lose claude.ai chat, Remote Control and cloud sessions. Tools that replace
163
- `ANTHROPIC_BASE_URL` never reach the desktop app at all. ClaudeRipple instead is a
228
+ `ANTHROPIC_BASE_URL` never reach the desktop app, and the ones that do reach it write
229
+ that same setting. ClaudeRipple instead is a
164
230
  tiny HTTPS proxy that only the Claude Code process trusts: requests for the models
165
231
  you map go to your provider, everything else goes to Anthropic byte for byte.
166
232
 
167
233
  ## What you get
168
234
 
169
- - **Any model in Claude Desktop and Claude Code.** GPT-5.6 Terra / Sol / Luna and
170
- GPT-6 Astra through your ChatGPT Plus/Pro subscription, or DeepSeek, Kimi, GLM,
235
+ - **Any model in Claude Desktop and Claude Code.** GPT-6 Sol / Luna / Astra and
236
+ GPT-5.6 Terra / Sol / Luna through your ChatGPT Plus/Pro subscription (new models
237
+ appear the day OpenAI serves them), or DeepSeek, Kimi, GLM,
171
238
  MiniMax, Qwen, Grok, Mistral, Groq, Together, Fireworks, OpenRouter (400+ models)
172
239
  and local Ollama / LM Studio. Under their real names in the picker, or mapped onto
173
240
  a Claude name.
@@ -219,6 +286,17 @@ curl -fsSL https://raw.githubusercontent.com/PBJ-2/clauderipple/main/scripts/ins
219
286
  irm https://raw.githubusercontent.com/PBJ-2/clauderipple/main/scripts/install.ps1 | iex
220
287
  ```
221
288
 
289
+ **Linux** (a desktop session with systemd)
290
+
291
+ ```sh
292
+ curl -fsSL https://raw.githubusercontent.com/PBJ-2/clauderipple/main/scripts/install.sh | sh
293
+ ```
294
+
295
+ The router runs as a per-user systemd service: `systemctl --user status clauderipple`, and
296
+ `journalctl --user -u clauderipple` for what it printed. Picker mode trusts the local CA in the
297
+ NSS database Chromium reads (`~/.pki/nssdb`, your user only, no sudo), which takes `certutil`:
298
+ run `sudo apt install libnss3-tools` (Fedora: `sudo dnf install nss-tools`) before `clauderipple picker on`.
299
+
222
300
  That is the whole prerequisite list. The script uses a Node 24+ you already have, and
223
301
  downloads the official build into `~/.clauderipple/runtime` when you have none,
224
302
  verifying it against the checksum nodejs.org publishes. Then it sets ClaudeRipple up:
@@ -260,12 +338,13 @@ clauderipple restart
260
338
  Optional, for real names in the Desktop picker: **Clients → Claude Desktop → Model
261
339
  picker → on**, then quit and reopen Claude Desktop. To trust the local certificate
262
340
  for your user only, macOS asks for your login password and Windows shows a
263
- confirmation dialog with the fingerprint — ClaudeRipple never sees a password, and
264
- neither platform needs administrator rights.
341
+ confirmation dialog with the fingerprint; Linux adds it to your NSS database without asking.
342
+ ClaudeRipple never sees a password, and no platform needs administrator rights.
265
343
 
266
344
  > **Closing Claude Desktop's window is not enough.** It keeps running, and the next
267
345
  > launch reuses it without reading the new setting. Quit it properly (macOS: ⌘Q;
268
- > Windows: the tray icon, or Task Manager) or the picker will silently not change.
346
+ > Windows: the tray icon, or Task Manager; Linux: Ctrl+Q or the tray icon, until
347
+ > `pgrep -f claude-desktop` prints nothing) or the picker will silently not change.
269
348
 
270
349
  <details>
271
350
  <summary>From source (Node 24)</summary>
@@ -373,7 +452,7 @@ provider you configured is available the same way.
373
452
 
374
453
  | Provider | Kind | Auth | Model list | Notes |
375
454
  |---|---|---|---|---|
376
- | ChatGPT subscription | Codex backend | several sign-ins, switching when one runs out (Codex login reused too) | Terra, Sol, Luna, Astra | effort low…max (Luna: ultra), prompt cache 94–99 % |
455
+ | ChatGPT subscription | Codex backend | several sign-ins, switching when one runs out (Codex login reused too) | read from your subscription (GPT-6 Sol, Luna, Astra, GPT-5.6 …) | effort low…max (Luna: ultra), prompt cache 94–99 % |
377
456
  | OpenRouter | Anthropic-compatible | API key | 400+, discovered | per-model effort support read from the API |
378
457
  | DeepSeek, Kimi, Z.ai GLM, MiniMax, Qwen (intl / cn) | Anthropic-compatible | API key | preset | verified against vendor docs |
379
458
  | xAI Grok, Mistral, Groq, Together, Fireworks | OpenAI-compatible | API key | discovered | translated (Chat Completions / Responses) |
@@ -422,6 +501,9 @@ In daily use by the author, but young. Expect rough edges:
422
501
  in, the picker and a live GPT call are verified on x64 hardware; crash recovery
423
502
  on arm64. The window still has the stock Windows title bar. Windows builds are
424
503
  unsigned; see the note under Install.
504
+ - **Linux support is new (2026-09-24)**, contributed with measurements on Ubuntu
505
+ 24.04 x64 (install, a Code-tab request, restart mid-stream, picker mode, uninstall).
506
+ It needs a systemd user session; other distributions and arm64 are untested.
425
507
  - ChatGPT, OpenRouter and Claude-in-Codex are verified with live accounts; the
426
508
  other presets follow the vendors' official documentation.
427
509
  - A model added to the picker is usable from the next session.
@@ -0,0 +1,4 @@
1
+ {
2
+ "private": true,
3
+ "type": "commonjs"
4
+ }
@@ -1,6 +1,7 @@
1
1
  // Opens a URL in the user's default browser. Shared by the CLI (sign-in commands, `ui`) and the
2
2
  // router's admin API (sign-in started from the GUI).
3
3
  import { execFileSync } from "node:child_process";
4
+ import { windowsPowerShellEnv } from "./windows-powershell.js";
4
5
  /** Returns false if the platform command failed; the caller then shows the URL instead. */
5
6
  export function openBrowser(url) {
6
7
  try {
@@ -9,10 +10,10 @@ export function openBrowser(url) {
9
10
  // at its first parameter and the provider answers "missing_required_parameter" (observed
10
11
  // 2026-09-14). Start-Process takes the URL as one argument, quoted PowerShell-style.
11
12
  const quoted = `'${url.replace(/'/g, "''")}'`;
12
- execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `Start-Process ${quoted}`], { stdio: "ignore", windowsHide: true });
13
+ execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `Start-Process ${quoted}`], { env: windowsPowerShellEnv(), stdio: "ignore", windowsHide: true });
13
14
  }
14
15
  else
15
- execFileSync("open", [url], { stdio: "ignore" });
16
+ execFileSync(process.platform === "linux" ? "xdg-open" : "open", [url], { stdio: "ignore" });
16
17
  return true;
17
18
  }
18
19
  catch {
@@ -19,6 +19,10 @@ const isWindows = process.platform === "win32";
19
19
  const BINARY_NAMES = isWindows ? ["claude.exe", "claude.cmd", "claude.bat"] : ["claude"];
20
20
  /** Where Claude Desktop caches the CLI it downloads, per platform. */
21
21
  export function desktopClaudeCodeDirs() {
22
+ // Linux: Electron's userData is $XDG_CONFIG_HOME/Claude (seen with Claude Desktop 2.2553.13 on
23
+ // Ubuntu 24.04: ~/.config/Claude/claude-code/2.1.280/claude, a plain executable).
24
+ if (process.platform === "linux")
25
+ return [path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "Claude", "claude-code")];
22
26
  if (!isWindows)
23
27
  return [path.join(os.homedir(), "Library", "Application Support", "Claude", "claude-code")];
24
28
  const roots = [
@@ -29,6 +33,8 @@ export function desktopClaudeCodeDirs() {
29
33
  }
30
34
  /** Launcher inside one cached version directory. The macOS build nests an .app bundle. */
31
35
  function versionBinaries(versionDir) {
36
+ if (process.platform === "linux")
37
+ return [path.join(versionDir, "claude")];
32
38
  return isWindows
33
39
  ? BINARY_NAMES.map((name) => path.join(versionDir, name))
34
40
  : [path.join(versionDir, "claude.app", "Contents", "MacOS", "claude")];
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // clauderipple — install / uninstall / status / start / stop / restart / logs / config
3
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.
4
+ // install: generates the local CA + leaf, writes a starter config, registers the supervisor
5
+ // (launchd / Task Scheduler / systemd), probes the whole chain end to end, and only
6
+ // then points ~/.claude/settings.json env at the router.
7
7
  // uninstall: reverses exactly that. Home dir is kept unless --purge.
8
8
  import fs from "node:fs";
9
9
  import net from "node:net";
@@ -13,10 +13,10 @@ import { execFileSync } from "node:child_process";
13
13
  import { ConfigStore, DEFAULTS, homeDir, configPath } from "../../router/src/config.js";
14
14
  import { adminPort } from "../../router/src/admin.js";
15
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";
16
+ import { applyProxyEnv, checkProxyEnv, currentProxyEnv, removeProxyEnv, settingsPath } from "./settings.js";
17
+ import { agentDefinitionPath, agentState, installAgent, isLinux, isSupported, isWindows, removeAgent, restartAgent, startAgent, stopAgent, supervisorName } from "./supervisor.js";
18
18
  import { BUNDLE_ID, removeBundle, writeBundle } from "./bundle.js";
19
- import { applyAppProxy, caTrusted, currentAppProxy, removeAppProxy, trustCa, untrustCa } from "./picker.js";
19
+ import { applyAppProxy, caTrusted, currentAppProxy, nssDb, removeAppProxy, trustCa, untrustCa } from "./picker.js";
20
20
  import { runtime } from "./runtime.js";
21
21
  import { codexOff, codexOn } from "./codex.js";
22
22
  import { ingressModels } from "../../router/src/ingress/models.js";
@@ -36,14 +36,21 @@ async function pickerOn() {
36
36
  const caPem = certPaths(home).caPem;
37
37
  if (!fs.existsSync(path.join(home, "ca.key")))
38
38
  throw new Error("ca.key missing; run `clauderipple install` first");
39
+ // With picker mode on, every byte Claude Desktop sends goes through the router (ARCHITECTURE §5):
40
+ // pointing the app at one that is not answering leaves it a blank page, so check first.
41
+ const ready = await probe({ host: cfg.listen.host, port: cfg.listen.port, caPem, upstream: cfg.upstream });
42
+ if (!ready.ok)
43
+ throw new Error(`the router is not answering (${ready.detail}); picker mode not enabled. Start it with \`clauderipple start\` and try again.`);
39
44
  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
45
  console.log(isWindows
41
46
  ? "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).");
47
+ : isLinux
48
+ ? `Step 1/3: trusting the ClaudeRipple CA in your NSS database (${nssDb()}), which Chromium and so Claude Desktop reads. Your user only; no password, no sudo.`
49
+ : "Step 1/3: trusting the ClaudeRipple CA in your login keychain. macOS will ask for your password (ClaudeRipple never sees it).");
43
50
  trustCa(caPem);
44
- if (!caTrusted())
51
+ if (!caTrusted(caPem))
45
52
  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)");
53
+ console.log(isWindows || isLinux ? "✓ CA trusted (current user only)" : "✓ CA trusted (login keychain only)");
47
54
  const proxyUrl = proxyUrlFor(cfg.listen.port);
48
55
  const r = applyAppProxy(proxyUrl);
49
56
  console.log(`✓ Claude Desktop config library entry applied (${r.id}${r.replaced ? `, previous entry ${r.replaced} remembered` : ""}): egressProxyUrl=${proxyUrl}`);
@@ -72,7 +79,7 @@ function pickerOff() {
72
79
  }
73
80
  if (isWindows)
74
81
  console.log("Removing the CA: Windows will ask you to confirm once more.");
75
- const store = isWindows ? "your user certificate store" : "the login keychain";
82
+ const store = isWindows ? "your user certificate store" : isLinux ? `your NSS database (${nssDb()})` : "the login keychain";
76
83
  console.log(untrustCa(certPaths(home).caPem) ? `✓ CA removed from ${store}` : `✓ CA was not in ${store}`);
77
84
  console.log("\nQuit and reopen Claude Desktop to apply.");
78
85
  }
@@ -119,7 +126,7 @@ async function install() {
119
126
  const home = homeDir();
120
127
  const port = Number(opt("port") ?? DEFAULTS.listen.port);
121
128
  if (!isSupported)
122
- throw new Error(`install supports macOS and Windows; on ${process.platform} run the router manually.`);
129
+ throw new Error(`install supports macOS, Windows and Linux (systemd); on ${process.platform} run the router manually.`);
123
130
  fs.mkdirSync(home, { recursive: true, mode: 0o700 });
124
131
  if (!certsExist(home)) {
125
132
  generateCerts(home, DEFAULTS.upstream);
@@ -137,10 +144,10 @@ async function install() {
137
144
  const proxyUrl = proxyUrlFor(cfg.listen.port);
138
145
  const caPath = certPaths(home).caPem;
139
146
  const maxCtx = opt("max-context-tokens");
140
- const edit = applyProxyEnv({ proxyUrl, caPath, force: flag("force"), ...(maxCtx ? { maxContextTokens: Number(maxCtx) } : {}), models: cfg.cli.models ?? {} });
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}`);
147
+ // settings.json is written last, after the probe: from that write on, every new Claude Code
148
+ // session sends everything to this router, so it must not point at one that never came up.
149
+ // A foreign proxy is still refused here, before anything is registered.
150
+ checkProxyEnv({ proxyUrl, force: flag("force") });
144
151
  // Persist the single execution contract so the GUI, admin API, hooks, and supervisor all use
145
152
  // this installation's runtime rather than any Node installation on the user's PATH.
146
153
  fs.writeFileSync(path.join(home, "paths.json"), JSON.stringify({
@@ -151,9 +158,9 @@ async function install() {
151
158
  hookScript: installedRuntime.hookScript,
152
159
  repo: installedRuntime.repo,
153
160
  }, 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) {
161
+ // Windows and Linux register node + the router script directly (schtasks.ts writes its own .cmd
162
+ // launcher); a macOS source checkout gets a small .app so the agent shows a real name in Login Items.
163
+ if (installedRuntime.packaged || isWindows || isLinux) {
157
164
  const plist = installAgent({
158
165
  program: installedRuntime.node,
159
166
  args: [installedRuntime.router],
@@ -161,7 +168,7 @@ async function install() {
161
168
  home,
162
169
  env: { ...installedRuntime.env, ...(process.env.CLAUDE_SETTINGS_PATH ? { CLAUDE_SETTINGS_PATH: process.env.CLAUDE_SETTINGS_PATH } : {}) },
163
170
  });
164
- console.log(`✓ ${supervisorName()} registered: ${plist} (shows as "ClaudeRipple" in Login Items)`);
171
+ console.log(`✓ ${supervisorName()} registered: ${plist}${isLinux ? "" : ' (shows as "ClaudeRipple" in Login Items)'}`);
165
172
  }
166
173
  else {
167
174
  const launcher = writeBundle({ home, node: installedRuntime.node, script: installedRuntime.router, version: VERSION });
@@ -180,10 +187,16 @@ async function install() {
180
187
  console.log(started === "failed" ? `✗ could not start the router (${supervisorName()})` : `✓ router ${started === "already-running" ? "already running" : "started"}`);
181
188
  const p = await probeWithRetry({ host: cfg.listen.host, port: cfg.listen.port, caPem: caPath, upstream: cfg.upstream });
182
189
  console.log(p.ok ? `✓ end-to-end probe passed (${p.detail}, ${p.ms}ms)` : `✗ probe failed: ${p.detail}`);
183
- if (!p.ok)
190
+ if (!p.ok) {
184
191
  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.");
192
+ console.log(`${settingsPath()} was left as it was, so Claude Code does not depend on a router that is not answering. Fix the router, then run install again.`);
193
+ return;
194
+ }
195
+ const edit = applyProxyEnv({ proxyUrl, caPath, force: flag("force"), ...(maxCtx ? { maxContextTokens: Number(maxCtx) } : {}), models: cfg.cli.models ?? {} });
196
+ console.log(edit.changed ? `✓ ${settingsPath()} updated (backup: ${edit.backup ?? "none"})` : `✓ ${settingsPath()} already correct`);
197
+ for (const n of edit.notes)
198
+ console.log(` note: ${n}`);
199
+ console.log("\nDone. New Claude Desktop Code sessions go through ClaudeRipple. Existing sessions pick up settings.json env changes on their next request.");
187
200
  }
188
201
  function uninstall() {
189
202
  const home = homeDir();
@@ -238,7 +251,7 @@ async function status() {
238
251
  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
252
  rows.push([supervisorName(), agentState()]);
240
253
  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`)" : ""}`]);
254
+ rows.push(["picker mode", cfg.picker?.enabled ? `on · CA ${caTrusted(caPath) ? "trusted" : "NOT trusted"} · app proxy ${ap.ours ? ap.egressProxyUrl : "NOT set"}` : `off${ap.ours ? " (app proxy entry still present — run `picker off`)" : ""}`]);
242
255
  const p = await probe({ host: cfg.listen.host, port: cfg.listen.port, caPem: caPath, upstream: cfg.upstream });
243
256
  rows.push(["probe", `${p.ok ? "ok" : "FAIL"}: ${p.detail} (${p.ms}ms)`]);
244
257
  for (const [name, p2] of Object.entries(cfg.providers)) {
@@ -1,6 +1,8 @@
1
1
  // `clauderipple picker on|off` — put real model names into Claude Desktop's model picker.
2
2
  //
3
- // on: 1. trust our CA in the login keychain (macOS asks the user for their password; we never see it)
3
+ // on: 1. trust our CA for this user: login keychain (macOS asks for the password; we never see it),
4
+ // Cert:\CurrentUser\Root (Windows shows a confirmation dialog), or the NSS database Chromium
5
+ // reads on Linux (~/.pki/nssdb; no prompt, no sudo)
4
6
  // 2. point the app itself at the router: Config Library entry {egressProxyUrl} + _meta.json appliedId
5
7
  // (~/Library/Application Support/Claude-3p/configLibrary/ — the app keeps its managed-config
6
8
  // library under the "-3p" userData dir in BOTH deployment modes; read once at start, no MDM needed)
@@ -8,10 +10,11 @@
8
10
  // → the user restarts Claude Desktop.
9
11
  // off: reverse all three.
10
12
  import { execFileSync } from "node:child_process";
11
- import crypto from "node:crypto";
13
+ import crypto, { X509Certificate } from "node:crypto";
12
14
  import fs from "node:fs";
13
15
  import os from "node:os";
14
16
  import path from "node:path";
17
+ import { windowsPowerShellEnv } from "./windows-powershell.js";
15
18
  export const CA_NAME = "ClaudeRipple local CA";
16
19
  function appSupport() {
17
20
  // Verified in index.pre.js (AW()): userData + "-3p" regardless of 1P/3P mode. Not the plain "Claude" dir.
@@ -22,6 +25,10 @@ function appSupport() {
22
25
  if (process.platform === "win32") {
23
26
  return path.join(process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local"), "Claude-3p");
24
27
  }
28
+ // Linux userData is $XDG_CONFIG_HOME/Claude, so the same "-3p" rule gives ~/.config/Claude-3p
29
+ // (Claude Desktop 2.2553.13's bundle: `${app.getPath("userData")}-3p`, no Linux special case).
30
+ if (process.platform === "linux")
31
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "Claude-3p");
25
32
  return path.join(os.homedir(), "Library", "Application Support", "Claude-3p");
26
33
  }
27
34
  export function configLibraryDir() {
@@ -31,6 +38,84 @@ function loginKeychain() {
31
38
  return path.join(os.homedir(), "Library", "Keychains", "login.keychain-db");
32
39
  }
33
40
  const isWindows = process.platform === "win32";
41
+ const isLinux = process.platform === "linux";
42
+ // ---- Linux: the NSS database ------------------------------------------------------------
43
+ //
44
+ // Chromium on Linux takes locally added roots from the user's NSS database, not from /etc/ssl,
45
+ // and Claude Desktop is Chromium: a CA only in the system bundle would leave the app a blank page
46
+ // behind the router. certutil comes from libnss3-tools, which a desktop install does not always have.
47
+ /** Test-only override; production always uses the database Chromium reads. */
48
+ export function nssDb() {
49
+ return process.env.CLAUDERIPPLE_NSS_DB ?? path.join(os.homedir(), ".pki", "nssdb");
50
+ }
51
+ /**
52
+ * The fingerprint is part of the name so a CA from an earlier install is never taken for the
53
+ * current one: trusting the old one would pass a check by name and still fail every handshake.
54
+ */
55
+ export function nssNickname(caPem) {
56
+ return `${CA_NAME} ${new X509Certificate(fs.readFileSync(caPem)).fingerprint256.replace(/:/g, "").slice(0, 24)}`;
57
+ }
58
+ function certutil(args) {
59
+ return execFileSync("certutil", ["-d", `sql:${nssDb()}`, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
60
+ }
61
+ /** Every ClaudeRipple CA in the database, including ones left by an earlier install. */
62
+ function nssOurNicknames() {
63
+ let out;
64
+ try {
65
+ out = certutil(["-L"]);
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ // One line per certificate: the nickname, then its trust flags ("C,,") as the last column.
71
+ return out
72
+ .split("\n")
73
+ .map((line) => /^(.*\S)\s+\S*,\S*,\S*\s*$/.exec(line)?.[1])
74
+ .filter((name) => !!name && name.startsWith(CA_NAME));
75
+ }
76
+ export function nssTrusted(caPem) {
77
+ try {
78
+ const name = nssNickname(caPem);
79
+ const installed = new X509Certificate(certutil(["-L", "-n", name, "-a"]));
80
+ if (installed.fingerprint256 !== new X509Certificate(fs.readFileSync(caPem)).fingerprint256)
81
+ return false;
82
+ // Validated as an SSL CA (-u L): that is the trust a handshake needs, not mere presence.
83
+ certutil(["-V", "-n", name, "-u", "L"]);
84
+ return true;
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
90
+ export function nssTrust(caPem) {
91
+ if (nssTrusted(caPem))
92
+ return;
93
+ try {
94
+ fs.mkdirSync(nssDb(), { recursive: true, mode: 0o700 });
95
+ if (!fs.existsSync(path.join(nssDb(), "cert9.db")))
96
+ certutil(["-N", "--empty-password"]);
97
+ certutil(["-A", "-n", nssNickname(caPem), "-t", "C,,", "-i", caPem]);
98
+ }
99
+ catch (e) {
100
+ if (e.code === "ENOENT") {
101
+ throw new Error("certutil not found. Install it (Ubuntu/Debian: `sudo apt install libnss3-tools`, Fedora: `sudo dnf install nss-tools`), then run `clauderipple picker on` again");
102
+ }
103
+ throw e;
104
+ }
105
+ }
106
+ /** Removes every ClaudeRipple CA, current or left over from an earlier install. */
107
+ export function nssUntrust() {
108
+ const names = nssOurNicknames();
109
+ for (const name of names) {
110
+ try {
111
+ certutil(["-D", "-n", name]);
112
+ }
113
+ catch {
114
+ /* already gone */
115
+ }
116
+ }
117
+ return names.length > 0;
118
+ }
34
119
  /** Single-quoted PowerShell literal; the only escape inside one is a doubled quote. */
35
120
  function ps(value) {
36
121
  return `'${value.replace(/'/g, "''")}'`;
@@ -45,19 +130,23 @@ function powershell(script, opts = {}) {
45
130
  const stdio = opts.stdio ?? "pipe";
46
131
  const args = ["-NoProfile", ...(opts.interactive ? [] : ["-NonInteractive"]), "-Command", script];
47
132
  return execFileSync("powershell.exe", args, {
133
+ env: windowsPowerShellEnv(),
48
134
  stdio: stdio === "pipe" ? ["ignore", "pipe", "pipe"] : stdio,
49
135
  windowsHide: !opts.interactive,
50
136
  })?.toString() ?? "";
51
137
  }
52
138
  /**
53
- * Both platforms trust the CA for the current user only — never machine-wide, which would need
139
+ * Every platform trusts the CA for the current user only — never machine-wide, which would need
54
140
  * administrator rights and would affect everyone on the box.
55
141
  * macOS : login keychain, and the OS asks for the account password.
56
142
  * Windows : Cert:\CurrentUser\Root, and the OS shows a confirmation dialog with the fingerprint.
143
+ * Linux : the user's NSS database (see nssDb), with no prompt at all.
57
144
  * Measured on Windows 11 (2026-09-14) as a standard user: the import succeeded with no UAC prompt,
58
145
  * only that dialog. Removal shows a second confirmation, so `picker off` prompts the user too.
59
146
  */
60
- export function caTrusted() {
147
+ export function caTrusted(caPem) {
148
+ if (isLinux)
149
+ return nssTrusted(caPem);
61
150
  try {
62
151
  if (isWindows) {
63
152
  const out = powershell(`@(Get-ChildItem Cert:\\CurrentUser\\Root | Where-Object { $_.Subject -eq 'CN=${CA_NAME}' }).Count`);
@@ -72,7 +161,9 @@ export function caTrusted() {
72
161
  }
73
162
  /** Adds the CA as a trusted root for the current user. The OS shows its own prompt; we never see a password. */
74
163
  export function trustCa(caPem) {
75
- if (caTrusted())
164
+ if (isLinux)
165
+ return nssTrust(caPem);
166
+ if (caTrusted(caPem))
76
167
  return;
77
168
  if (isWindows) {
78
169
  // stdio inherit: the confirmation dialog is the OS's, but errors should reach the user's terminal.
@@ -82,7 +173,9 @@ export function trustCa(caPem) {
82
173
  execFileSync("security", ["add-trusted-cert", "-r", "trustRoot", "-k", loginKeychain(), caPem], { stdio: "inherit" });
83
174
  }
84
175
  export function untrustCa(caPem) {
85
- if (!caTrusted())
176
+ if (isLinux)
177
+ return nssUntrust();
178
+ if (!caTrusted(caPem))
86
179
  return false;
87
180
  if (isWindows) {
88
181
  try {
@@ -16,6 +16,7 @@ import { execFileSync } from "node:child_process";
16
16
  import fs from "node:fs";
17
17
  import os from "node:os";
18
18
  import path from "node:path";
19
+ import { windowsPowerShellEnv } from "./windows-powershell.js";
19
20
  export const TASK_NAME = "ClaudeRippleRouter";
20
21
  export function taskName() {
21
22
  return process.env.CLAUDERIPPLE_TASK_NAME ?? TASK_NAME;
@@ -27,6 +28,7 @@ function ps(value) {
27
28
  function run(script) {
28
29
  try {
29
30
  const out = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
31
+ env: windowsPowerShellEnv(),
30
32
  stdio: ["ignore", "pipe", "pipe"],
31
33
  windowsHide: true,
32
34
  }).toString();
@@ -36,15 +36,22 @@ export const MODEL_SLOT_ENV = {
36
36
  smallFast: "ANTHROPIC_SMALL_FAST_MODEL",
37
37
  subagent: "CLAUDE_CODE_SUBAGENT_MODEL",
38
38
  };
39
+ function refuseForeignProxy(existing, proxyUrl, force) {
40
+ if (existing && existing !== proxyUrl && !force) {
41
+ throw new Error(`settings.json env.HTTPS_PROXY is already "${existing}". Re-run with --force to replace it, or uninstall the other proxy first.`);
42
+ }
43
+ }
44
+ /** The refusal `applyProxyEnv` would give, without writing: lets `install` fail before it registers anything. */
45
+ export function checkProxyEnv(opts) {
46
+ refuseForeignProxy(currentProxyEnv().HTTPS_PROXY, opts.proxyUrl, opts.force);
47
+ }
39
48
  export function applyProxyEnv(opts) {
40
49
  const file = settingsPath();
41
50
  const s = readSettings(file);
42
51
  const env = { ...(s.env ?? {}) };
43
52
  const notes = [];
44
53
  const existing = env.HTTPS_PROXY;
45
- if (existing && existing !== opts.proxyUrl && !opts.force) {
46
- throw new Error(`settings.json env.HTTPS_PROXY is already "${existing}". Re-run with --force to replace it, or uninstall the other proxy first.`);
47
- }
54
+ refuseForeignProxy(existing, opts.proxyUrl, opts.force);
48
55
  if (existing && existing !== opts.proxyUrl)
49
56
  notes.push(`replaced HTTPS_PROXY ${existing}`);
50
57
  const want = { HTTPS_PROXY: opts.proxyUrl, NODE_EXTRA_CA_CERTS: opts.caPath };
@@ -1,32 +1,38 @@
1
- // One supervisor interface, two implementations: launchd on macOS, Task Scheduler on Windows.
1
+ // One supervisor interface, three implementations: launchd on macOS, Task Scheduler on Windows,
2
+ // a systemd user service on Linux.
2
3
  // Everything outside this module talks to the router's lifecycle through here.
3
4
  import { execFileSync } from "node:child_process";
4
5
  import { ConfigStore, configPath } from "../../router/src/config.js";
5
6
  import { adminPort } from "../../router/src/admin.js";
6
7
  import * as launchd from "./launchd.js";
7
8
  import * as schtasks from "./schtasks.js";
9
+ import * as systemd from "./systemd.js";
10
+ import { windowsPowerShellEnv } from "./windows-powershell.js";
8
11
  export const isWindows = process.platform === "win32";
9
- export const isSupported = isWindows || process.platform === "darwin";
12
+ export const isLinux = process.platform === "linux";
13
+ export const isSupported = isWindows || isLinux || process.platform === "darwin";
10
14
  /** What the supervisor is called on this platform, for messages the user reads. */
11
15
  export function supervisorName() {
12
- return isWindows ? "scheduled task" : "launchd agent";
16
+ return isWindows ? "scheduled task" : isLinux ? "systemd user service" : "launchd agent";
13
17
  }
14
18
  export function installAgent(opts) {
15
- return isWindows ? schtasks.installAgent(opts) : launchd.installAgent(opts);
19
+ return isWindows ? schtasks.installAgent(opts) : isLinux ? systemd.installAgent(opts) : launchd.installAgent(opts);
16
20
  }
17
21
  export function removeAgent() {
18
- return isWindows ? schtasks.removeAgent() : launchd.removeAgent();
22
+ return isWindows ? schtasks.removeAgent() : isLinux ? systemd.removeAgent() : launchd.removeAgent();
19
23
  }
20
24
  export function agentState() {
21
- return isWindows ? schtasks.agentState() : launchd.agentState();
25
+ return isWindows ? schtasks.agentState() : isLinux ? systemd.agentState() : launchd.agentState();
22
26
  }
23
27
  export function agentPid() {
24
- return isWindows ? schtasks.agentPid() : launchd.agentPid();
28
+ return isWindows ? schtasks.agentPid() : isLinux ? systemd.agentPid() : launchd.agentPid();
25
29
  }
26
30
  export function startAgent() {
27
- return isWindows ? schtasks.startAgent() : launchd.startAgent();
31
+ return isWindows ? schtasks.startAgent() : isLinux ? systemd.startAgent() : launchd.startAgent();
28
32
  }
29
33
  export function stopAgent() {
34
+ if (isLinux)
35
+ return systemd.stopAgent();
30
36
  if (!isWindows)
31
37
  return launchd.stopAgent();
32
38
  // Ask the router to drain first: a clean exit also ends the launcher's supervision loop, so the
@@ -38,7 +44,7 @@ export function stopAgent() {
38
44
  /** Blocking POST to the router's shutdown endpoint. Returns false if it did not answer. */
39
45
  function askShutdown() {
40
46
  try {
41
- execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `Invoke-RestMethod -Method Post -Uri '${adminUrl()}/api/shutdown' -TimeoutSec 10 | Out-Null`], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
47
+ execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `Invoke-RestMethod -Method Post -Uri '${adminUrl()}/api/shutdown' -TimeoutSec 10 | Out-Null`], { env: windowsPowerShellEnv(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
42
48
  return true;
43
49
  }
44
50
  catch {
@@ -92,9 +98,9 @@ function restartWindows(opts) {
92
98
  return schtasks.startAgent() === "failed" ? "failed" : "drained";
93
99
  }
94
100
  export function restartAgent(opts = {}) {
95
- return isWindows ? restartWindows(opts) : launchd.restartAgent(opts);
101
+ return isWindows ? restartWindows(opts) : isLinux ? systemd.restartAgent(opts) : launchd.restartAgent(opts);
96
102
  }
97
103
  /** Path to the supervisor's own definition, for status output. */
98
104
  export function agentDefinitionPath() {
99
- return isWindows ? `Task Scheduler\\${schtasks.taskName()}` : launchd.plistPath();
105
+ return isWindows ? `Task Scheduler\\${schtasks.taskName()}` : isLinux ? systemd.unitPath() : launchd.plistPath();
100
106
  }
@@ -0,0 +1,126 @@
1
+ // Linux supervisor: a per-user systemd service that keeps the router alive. No sudo, nothing
2
+ // machine-wide. Restart=always + RestartSec=2 is launchd's KeepAlive + ThrottleInterval 2: a
3
+ // self-exit (code 75) is followed by a fresh process two seconds later, and StartLimitIntervalSec=0
4
+ // keeps systemd from giving up on it after a burst of those.
5
+ import { execFileSync } from "node:child_process";
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ const HEADER = "[Unit]\nDescription=ClaudeRipple local model router\n";
10
+ /** Test-only override; production always uses clauderipple.service. */
11
+ export function unitName() {
12
+ const name = process.env.CLAUDERIPPLE_SYSTEMD_UNIT ?? "clauderipple.service";
13
+ if (!/^[A-Za-z0-9_.-]+\.service$/.test(name))
14
+ throw new Error(`invalid systemd unit name: ${name}`);
15
+ return name;
16
+ }
17
+ export function unitPath() {
18
+ const config = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
19
+ return path.join(config, "systemd", "user", unitName());
20
+ }
21
+ /**
22
+ * One quoted unit-file word. `%` is a specifier and `$` an environment substitution in ExecStart
23
+ * (systemd.service(5)), so a path containing either has to be escaped or it runs something else.
24
+ */
25
+ function quote(value, dollar) {
26
+ if (/[\r\n\0]/.test(value))
27
+ throw new Error("newlines and NUL cannot appear in a systemd unit value");
28
+ let s = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%");
29
+ if (dollar)
30
+ s = s.replace(/\$/g, "$$$$");
31
+ return `"${s}"`;
32
+ }
33
+ export function renderUnit(opts) {
34
+ if (!path.isAbsolute(opts.program))
35
+ throw new Error("the service program must be an absolute path");
36
+ const env = { CLAUDERIPPLE_HOME: opts.home, ...(opts.env ?? {}) };
37
+ const envLines = Object.entries(env).map(([k, v]) => {
38
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k))
39
+ throw new Error(`invalid environment name: ${k}`);
40
+ return `Environment=${quote(`${k}=${v}`, false)}`;
41
+ });
42
+ // TimeoutStopSec covers the router's 90s drain on SIGTERM (ARCHITECTURE §5) with room to spare;
43
+ // KillMode=mixed sends that SIGTERM to the router alone and SIGKILLs only what outlives the budget.
44
+ return `${HEADER}StartLimitIntervalSec=0
45
+
46
+ [Service]
47
+ Type=simple
48
+ ExecStart=${[opts.program, ...(opts.args ?? [])].map((a) => quote(a, true)).join(" ")}
49
+ ${envLines.join("\n")}
50
+ Restart=always
51
+ RestartSec=2
52
+ KillSignal=SIGTERM
53
+ KillMode=mixed
54
+ TimeoutStopSec=120
55
+ UMask=0077
56
+
57
+ [Install]
58
+ WantedBy=default.target
59
+ `;
60
+ }
61
+ function systemctl(args) {
62
+ try {
63
+ const out = execFileSync("systemctl", ["--user", ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 150_000 });
64
+ return { ok: true, out };
65
+ }
66
+ catch (e) {
67
+ const err = e;
68
+ return { ok: false, out: err.stderr?.toString().trim() || err.message || "systemctl failed" };
69
+ }
70
+ }
71
+ function mustSystemctl(args) {
72
+ const r = systemctl(args);
73
+ if (!r.ok)
74
+ throw new Error(`systemctl --user ${args.join(" ")} failed: ${r.out}`);
75
+ }
76
+ export function installAgent(opts) {
77
+ // No user manager (an ssh login without lingering, a container) means nothing would ever start
78
+ // the router: fail here, before any file is written.
79
+ mustSystemctl(["show-environment"]);
80
+ const file = unitPath();
81
+ if (fs.existsSync(file) && !fs.readFileSync(file, "utf8").startsWith(HEADER)) {
82
+ throw new Error(`${file} exists and is not ClaudeRipple's; refusing to replace it`);
83
+ }
84
+ fs.mkdirSync(path.dirname(file), { recursive: true });
85
+ fs.writeFileSync(file, renderUnit(opts), { mode: 0o600 });
86
+ mustSystemctl(["daemon-reload"]);
87
+ mustSystemctl(["enable", unitName()]);
88
+ return file;
89
+ }
90
+ export function removeAgent() {
91
+ const file = unitPath();
92
+ if (!fs.existsSync(file))
93
+ return false;
94
+ mustSystemctl(["disable", "--now", unitName()]);
95
+ fs.unlinkSync(file);
96
+ mustSystemctl(["daemon-reload"]);
97
+ return true;
98
+ }
99
+ export function agentState() {
100
+ const r = systemctl(["show", unitName(), "--property=ActiveState", "--value"]);
101
+ if (r.ok && r.out.trim() === "active")
102
+ return "running";
103
+ return fs.existsSync(unitPath()) ? "loaded" : "not-loaded";
104
+ }
105
+ export function agentPid() {
106
+ const r = systemctl(["show", unitName(), "--property=MainPID", "--value"]);
107
+ const pid = Number(r.out.trim());
108
+ return r.ok && Number.isSafeInteger(pid) && pid > 0 ? pid : null;
109
+ }
110
+ /** Idempotent, like launchd's: a running router is left alone (ARCHITECTURE §5, "Start Router"). */
111
+ export function startAgent() {
112
+ if (agentState() === "running")
113
+ return "already-running";
114
+ return systemctl(["start", unitName()]).ok ? "started" : "failed";
115
+ }
116
+ export function stopAgent() {
117
+ return systemctl(["stop", unitName()]).ok;
118
+ }
119
+ /**
120
+ * systemd sends the SIGTERM, waits for the process to exit (up to TimeoutStopSec) and then starts
121
+ * a new one, so the drain is never cut the way `launchctl kickstart -k` cut it.
122
+ */
123
+ export function restartAgent(opts = {}) {
124
+ opts.onProgress?.("waiting for in-flight model calls to finish (up to 120s)…");
125
+ return systemctl(["restart", unitName()]).ok ? "drained" : "failed";
126
+ }
@@ -0,0 +1,13 @@
1
+ // Environment for every powershell.exe (Windows PowerShell 5.1) the CLI and router start.
2
+ //
3
+ // PowerShell 7 puts its own module directories at the front of PSModulePath. pwsh strips them
4
+ // when it starts powershell.exe itself, but not when another process sits in between: from a pwsh
5
+ // terminal the chain pwsh → node → powershell.exe hands 5.1 the PowerShell 7 copy of
6
+ // Microsoft.PowerShell.Security, which fails to load, and the Cert: drive goes with it.
7
+ // `picker on` then stopped at "Cannot find drive 'Cert'" and `status` read a trusted CA as
8
+ // untrusted (measured on Windows 11 with PowerShell 7.6.6, issue #35). Without the variable,
9
+ // Windows PowerShell rebuilds its own default from the registry.
10
+ /** process.env minus PSModulePath, matched without regard to case as Windows does. */
11
+ export function windowsPowerShellEnv() {
12
+ return Object.fromEntries(Object.entries(process.env).filter(([k]) => k.toLowerCase() !== "psmodulepath"));
13
+ }
@@ -27,6 +27,7 @@ import { codexEnabled, codexHome } from "../../cli/src/codex.js";
27
27
  import { openBrowser } from "../../cli/src/browser.js";
28
28
  import { caTrusted, currentAppProxy } from "../../cli/src/picker.js";
29
29
  import { certPaths } from "../../cli/src/certs.js";
30
+ import { desktopClaudeCodeDirs } from "../../cli/src/claude-auth.js";
30
31
  import { syncModelSlots } from "../../cli/src/settings.js";
31
32
  import { ClaudeOAuthSession } from "./providers/claude-oauth.js";
32
33
  import { readClaudeAuthFile } from "./providers/anthropic-token-file.js";
@@ -153,18 +154,23 @@ function uiRevision() {
153
154
  return "0";
154
155
  }
155
156
  }
157
+ /** Newest CLI the Desktop app has cached, from the same per-platform directories sign-in uses. */
156
158
  function cliVersion() {
157
- const dir = path.join(os.homedir(), "Library", "Application Support", "Claude", "claude-code");
158
- try {
159
- const versions = fs
160
- .readdirSync(dir)
161
- .filter((d) => /^\d+\.\d+\.\d+$/.test(d))
162
- .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
163
- return versions.length ? versions[versions.length - 1] : "none";
159
+ let readable = false;
160
+ const versions = [];
161
+ for (const dir of desktopClaudeCodeDirs()) {
162
+ try {
163
+ versions.push(...fs.readdirSync(dir).filter((d) => /^\d+\.\d+\.\d+$/.test(d)));
164
+ readable = true;
165
+ }
166
+ catch {
167
+ /* not this platform's layout, or no Desktop app */
168
+ }
164
169
  }
165
- catch {
170
+ if (!readable)
166
171
  return "n/a";
167
- }
172
+ versions.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
173
+ return versions.at(-1) ?? "none";
168
174
  }
169
175
  function tcpReachable(hostname, port, timeoutMs = 2000) {
170
176
  return new Promise((resolveP) => {
@@ -328,7 +334,7 @@ function pickerTrust() {
328
334
  return pickerTrustMemo.value;
329
335
  let value = { caTrusted: false, appProxy: false };
330
336
  try {
331
- value = { caTrusted: caTrusted(), appProxy: currentAppProxy().ours };
337
+ value = { caTrusted: caTrusted(certPaths(homeDir()).caPem), appProxy: currentAppProxy().ours };
332
338
  }
333
339
  catch {
334
340
  // Unknown counts as not ready; the next poll tries again.
@@ -762,6 +762,17 @@ export class Proxy {
762
762
  tag = `CLAUDE ${route.tag} account=${using.ownerId?.slice(0, 8) ?? "current"}`;
763
763
  }
764
764
  else {
765
+ // An Anthropic-shaped endpoint keeps no server-side thread either, so a `continue` is refused
766
+ // here as it is for the translated providers. sanitizeForCompatible used to strip the field
767
+ // and forward the rest, which is only the delta: DeepSeek (via Bailian) received two orphan
768
+ // tool_results with no task and answered that none had been given (measured 2026-09-24, CLI
769
+ // 2.1.280). Refused, the CLI resends the turn with the full history.
770
+ if (threadDecision(json) === "refuse") {
771
+ const out = JSON.stringify(THREAD_UNSUPPORTED);
772
+ res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
773
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false);
774
+ return;
775
+ }
765
776
  const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
766
777
  const modelEffortLevels = provider.models?.find((entry) => entry.id === route.model)?.effortLevels;
767
778
  compatCaps = resolveCompatibleCaps(preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking, ...(preset.serverTools ? { serverTools: true } : {}) } : undefined, modelEffortLevels === undefined ? provider.caps : { ...provider.caps, effortLevels: modelEffortLevels });
@@ -5,4 +5,4 @@
5
5
  // from the manifests. Until 0.1.1 the router and the CLI each carried their own literal, and both
6
6
  // still said "0.1.0" in the 0.1.1 release: after an update nobody could tell which router was
7
7
  // running, and the tray app had nothing to compare (2026-09-15, reported from a Windows install).
8
- export const VERSION = "0.4.0";
8
+ export const VERSION = "0.5.0";
@@ -493,7 +493,9 @@ chat is out of reach for every approach, ours included.
493
493
  turn stateless and keeps the session stateless on that model. Accepting the delta
494
494
  instead made the model see an orphan `tool_result`, answer "무엇을 도와드릴까요?", and
495
495
  kept `cached_tokens` stuck at the tools prefix. `thread`/`diagnostics` are stripped
496
- from `create` requests before forwarding.
496
+ from `create` requests before forwarding. An `anthropic-compatible` vendor holds no
497
+ thread either and is refused the same way: forwarding the stripped delta sent DeepSeek
498
+ (via Bailian) two orphan `tool_result`s and no task (measured 2026-09-24, CLI 2.1.280, #29).
497
499
  - **Per-turn billing telemetry.** The first system block is
498
500
  `x-anthropic-billing-header: … cch=<hash> …` and the hash changes every turn. Dropped
499
501
  from `instructions`, otherwise nothing after it is ever cached. With it dropped and
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clauderipple",
3
- "version": "0.4.0",
4
- "description": "Use GPT and 400+ other models inside the Claude Desktop app while staying signed in to your Claude subscription — no third-party gateway mode.",
3
+ "version": "0.5.0",
4
+ "description": "Use GPT (ChatGPT Plus/Pro), DeepSeek and 400+ other models in the Claude Desktop Code tab and Claude Code while staying signed in to your Claude subscription — no third-party gateway mode. Several ChatGPT accounts that take over when one runs out, in Codex too.",
5
5
  "license": "GPL-3.0-only",
6
6
  "workspaces": [
7
7
  "packages/*"
@@ -58,7 +58,11 @@
58
58
  "proxy",
59
59
  "model-router",
60
60
  "ai-coding-assistant",
61
- "mcp"
61
+ "codex-cli",
62
+ "chatgpt-plus",
63
+ "claude-code-proxy",
64
+ "llm-gateway",
65
+ "gpt-6"
62
66
  ],
63
67
  "homepage": "https://github.com/PBJ-2/clauderipple",
64
68
  "bugs": {