clauderipple 0.4.1 → 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 +33 -0
- package/README.ko.md +96 -25
- package/README.md +115 -28
- package/dist/cli/src/browser.js +3 -2
- package/dist/cli/src/claude-auth.js +6 -0
- package/dist/cli/src/index.js +36 -23
- package/dist/cli/src/picker.js +99 -6
- package/dist/cli/src/schtasks.js +2 -0
- package/dist/cli/src/settings.js +10 -3
- package/dist/cli/src/supervisor.js +17 -11
- package/dist/cli/src/systemd.js +126 -0
- package/dist/cli/src/windows-powershell.js +13 -0
- package/dist/router/src/admin.js +16 -10
- package/dist/router/src/proxy.js +11 -0
- package/dist/router/src/version.js +1 -1
- package/docs/ARCHITECTURE.md +3 -1
- package/package.json +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
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
|
+
|
|
3
36
|
## 0.4.1 — 2026-09-24
|
|
4
37
|
|
|
5
38
|
### Fixed
|
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
|
-
됩니다.
|
|
77
|
-
|
|
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,43 +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
|
-
비슷한 도구들은
|
|
122
|
-
|
|
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 | claude-code-router | Claude Desktop 게이트웨이(3P) 모드 |
|
|
127
|
-
|
|
128
|
-
| 게이트웨이(3P) 모드 **없이** Claude **Desktop** Code 탭 | ✅ | ❌ | ❌ (정의상 불가) |
|
|
129
|
-
| claude.ai 채팅·Remote Control·클라우드 세션·커넥터 유지 | ✅ | ❌ | ❌ |
|
|
130
|
-
| Claude 구독과 GPT 구독 나란히 | ✅ | ❌ | ❌ |
|
|
131
|
-
| Desktop 피커에 실제 모델 이름 | ✅ | ❌ | 일부 |
|
|
132
|
-
| ChatGPT 계정 여러 개, 한도 차면 자동 전환 (Desktop·Codex) | ✅ | ❌ | ❌ |
|
|
133
|
-
| 터미널 `claude` CLI | ✅ | ✅ | ✅ |
|
|
134
|
-
| Codex **앱**·Codex CLI → Claude | ✅ | ❌ | ❌ |
|
|
135
|
-
| 번역 프로바이더의 프롬프트 캐시 | **94~99 %** 실측 | 제각각 | 해당 없음 |
|
|
136
|
-
| 서브에이전트를 실제 모델 이름으로 표시 | ✅ | ❌ | ❌ |
|
|
137
|
-
| 터미널 없이 쓰는 설정 GUI | ✅ | ❌ | ❌ |
|
|
138
|
-
| 런타임 내장, 서명·공증된 앱 | ✅ | ❌ | – |
|
|
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 릴리스 노트).
|
|
139
194
|
|
|
140
195
|
데스크톱 앱의 "서드파티 추론" 설정은 앱 전체를 다른 모드로 바꿔 버립니다. claude.ai 채팅, Remote Control,
|
|
141
|
-
클라우드 세션을 잃습니다. `ANTHROPIC_BASE_URL`을 바꿔치기하는 도구들은 데스크톱 앱에
|
|
196
|
+
클라우드 세션을 잃습니다. `ANTHROPIC_BASE_URL`을 바꿔치기하는 도구들은 데스크톱 앱에 닿지 못하고, 닿는 도구는 바로 그
|
|
197
|
+
설정을 씁니다.
|
|
142
198
|
ClaudeRipple은 Claude Code 프로세스만 신뢰하는 작은 HTTPS 프록시입니다. 매핑한 모델 요청만 프로바이더로 가고,
|
|
143
199
|
나머지는 Anthropic으로 바이트 그대로 지나갑니다.
|
|
144
200
|
|
|
145
201
|
## 할 수 있는 것
|
|
146
202
|
|
|
147
|
-
- **Claude Desktop과 Claude Code에서 어떤 모델이든.** ChatGPT Plus/Pro 구독으로 GPT-
|
|
148
|
-
GPT-6
|
|
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+ 모델)·
|
|
149
205
|
로컬 Ollama / LM Studio를. 피커에 실제 이름으로 띄우거나, Claude 이름에 매핑해서.
|
|
150
206
|
- **Codex 앱과 Codex CLI에서 Claude를.** Codex가 프로바이더로 인식하는 로컬 OpenAI 호환 엔드포인트. Claude 모델이
|
|
151
207
|
Codex 자체 모델 목록에 뜹니다. 현재 Claude Code 로그인이나 Anthropic API 키를 씁니다.
|
|
@@ -184,6 +240,17 @@ curl -fsSL https://raw.githubusercontent.com/PBJ-2/clauderipple/main/scripts/ins
|
|
|
184
240
|
irm https://raw.githubusercontent.com/PBJ-2/clauderipple/main/scripts/install.ps1 | iex
|
|
185
241
|
```
|
|
186
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
|
+
|
|
187
254
|
준비물은 없습니다. 이미 Node 24 이상이 있으면 그것을 쓰고, 없으면 공식 빌드를 `~/.clauderipple/runtime`에
|
|
188
255
|
내려받습니다. 내려받은 파일은 nodejs.org가 공개한 체크섬과 대조합니다. 그다음 설치까지 마칩니다. 로컬 인증서,
|
|
189
256
|
`~/.claude/settings.json` 두 줄, 컴퓨터를 켤 때 함께 뜨는 백그라운드 라우터. 관리자 권한도, 암호 입력도 필요
|
|
@@ -222,11 +289,12 @@ clauderipple restart
|
|
|
222
289
|
|
|
223
290
|
선택 사항, Desktop 피커에 실제 이름을 띄우려면: **클라이언트 → Claude Desktop → 모델 피커 → 켜기**. 그다음 Claude
|
|
224
291
|
Desktop을 완전히 종료했다가 다시 엽니다. 로컬 인증서를 사용자 범위로만 신뢰시키는데, 이때 macOS는 로그인 암호를
|
|
225
|
-
묻고 Windows는 지문이 적힌 확인 창을 띄웁니다.
|
|
226
|
-
없습니다.
|
|
292
|
+
묻고 Windows는 지문이 적힌 확인 창을 띄웁니다. Linux는 묻지 않고 NSS 데이터베이스에 등록합니다. ClaudeRipple은
|
|
293
|
+
암호를 보지 않으며, 어느 플랫폼도 관리자 권한이 필요 없습니다.
|
|
227
294
|
|
|
228
295
|
> **Claude Desktop은 창을 닫아도 종료되지 않습니다.** 그 상태로 다시 열면 이전 인스턴스를 재사용해서 새 설정을
|
|
229
|
-
> 읽지 않습니다. 제대로 종료하십시오(macOS는 ⌘Q, Windows는 트레이 아이콘 또는 작업
|
|
296
|
+
> 읽지 않습니다. 제대로 종료하십시오(macOS는 ⌘Q, Windows는 트레이 아이콘 또는 작업 관리자, Linux는 Ctrl+Q 또는
|
|
297
|
+
> 트레이 아이콘. `pgrep -f claude-desktop`이 아무것도 출력하지 않을 때까지). 안 그러면 피커가
|
|
230
298
|
> 아무 말 없이 그대로입니다.
|
|
231
299
|
|
|
232
300
|
<details>
|
|
@@ -321,7 +389,7 @@ Anthropic API 키로 갑니다. 구독 로그인 재사용은 Anthropic 약관
|
|
|
321
389
|
|
|
322
390
|
| 프로바이더 | 종류 | 인증 | 모델 목록 | 비고 |
|
|
323
391
|
|---|---|---|---|---|
|
|
324
|
-
| ChatGPT 구독 | Codex 백엔드 | 로그인 여러 개, 한도 차면 자동 전환(Codex 로그인도 재사용) |
|
|
392
|
+
| ChatGPT 구독 | Codex 백엔드 | 로그인 여러 개, 한도 차면 자동 전환(Codex 로그인도 재사용) | 구독에서 자동으로 읽음(GPT-6 Sol·Luna·Astra, GPT-5.6 …) | 추론 강도 low…max(Luna는 ultra), 프롬프트 캐시 94~99 % |
|
|
325
393
|
| OpenRouter | Anthropic 호환 | API 키 | 400+, 자동 검색 | 모델별 추론 강도 지원을 API에서 읽음 |
|
|
326
394
|
| DeepSeek, Kimi, Z.ai GLM, MiniMax, Qwen(국제/중국) | Anthropic 호환 | API 키 | 프리셋 | 벤더 공식 문서로 확인 |
|
|
327
395
|
| xAI Grok, Mistral, Groq, Together, Fireworks | OpenAI 호환 | API 키 | 자동 검색 | 번역(Chat Completions / Responses) |
|
|
@@ -365,6 +433,9 @@ Codex 앱 / CLI ──/v1/responses──▶ ClaudeRipple 입구 ──▶ Claud
|
|
|
365
433
|
- **Windows 지원은 이제 막 들어갔습니다(2026-09-14).** 설치·설정·라우터·로그인·피커·실제 GPT 호출까지 x64
|
|
366
434
|
실기에서 확인했고, 크래시 복구는 arm64에서 확인했습니다. 창은 아직 Windows 기본 제목 표시줄을 씁니다.
|
|
367
435
|
Windows 빌드는 서명되지 않았습니다(위 설치 항목 참고).
|
|
436
|
+
- **Linux 지원도 이제 막 들어갔습니다(2026-09-24).** 기여자가 Ubuntu 24.04 x64에서 설치·Code 탭 요청·스트리밍
|
|
437
|
+
도중 재시작·피커 모드·제거를 실측해 보내 주었습니다. systemd 사용자 세션이 필요하고, 다른 배포판과 arm64는
|
|
438
|
+
확인하지 않았습니다.
|
|
368
439
|
- ChatGPT, OpenRouter, Codex 안의 Claude는 실제 계정으로 검증했습니다. 나머지 프리셋은 벤더 공식 문서를 따릅니다.
|
|
369
440
|
- 피커에 추가한 모델은 다음 세션부터 쓸 수 있습니다.
|
|
370
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
|
|
84
|
-
|
|
85
|
-
|
|
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,47 +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
|
|
135
|
-
|
|
136
|
-
you switch models you lose the Claude-subscription side (claude.ai chat,
|
|
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 | 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 | ✅ | ❌ | 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 | 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 | ✅ | ❌ | – |
|
|
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).
|
|
155
225
|
|
|
156
226
|
The desktop app's own "third-party inference" setting flips the whole app into another
|
|
157
227
|
mode: you lose claude.ai chat, Remote Control and cloud sessions. Tools that replace
|
|
158
|
-
`ANTHROPIC_BASE_URL` never reach the desktop app
|
|
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
|
|
159
230
|
tiny HTTPS proxy that only the Claude Code process trusts: requests for the models
|
|
160
231
|
you map go to your provider, everything else goes to Anthropic byte for byte.
|
|
161
232
|
|
|
162
233
|
## What you get
|
|
163
234
|
|
|
164
|
-
- **Any model in Claude Desktop and Claude Code.** GPT-
|
|
165
|
-
GPT-6
|
|
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,
|
|
166
238
|
MiniMax, Qwen, Grok, Mistral, Groq, Together, Fireworks, OpenRouter (400+ models)
|
|
167
239
|
and local Ollama / LM Studio. Under their real names in the picker, or mapped onto
|
|
168
240
|
a Claude name.
|
|
@@ -214,6 +286,17 @@ curl -fsSL https://raw.githubusercontent.com/PBJ-2/clauderipple/main/scripts/ins
|
|
|
214
286
|
irm https://raw.githubusercontent.com/PBJ-2/clauderipple/main/scripts/install.ps1 | iex
|
|
215
287
|
```
|
|
216
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
|
+
|
|
217
300
|
That is the whole prerequisite list. The script uses a Node 24+ you already have, and
|
|
218
301
|
downloads the official build into `~/.clauderipple/runtime` when you have none,
|
|
219
302
|
verifying it against the checksum nodejs.org publishes. Then it sets ClaudeRipple up:
|
|
@@ -255,12 +338,13 @@ clauderipple restart
|
|
|
255
338
|
Optional, for real names in the Desktop picker: **Clients → Claude Desktop → Model
|
|
256
339
|
picker → on**, then quit and reopen Claude Desktop. To trust the local certificate
|
|
257
340
|
for your user only, macOS asks for your login password and Windows shows a
|
|
258
|
-
confirmation dialog with the fingerprint
|
|
259
|
-
|
|
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.
|
|
260
343
|
|
|
261
344
|
> **Closing Claude Desktop's window is not enough.** It keeps running, and the next
|
|
262
345
|
> launch reuses it without reading the new setting. Quit it properly (macOS: ⌘Q;
|
|
263
|
-
> Windows: the tray icon, or Task Manager
|
|
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.
|
|
264
348
|
|
|
265
349
|
<details>
|
|
266
350
|
<summary>From source (Node 24)</summary>
|
|
@@ -368,7 +452,7 @@ provider you configured is available the same way.
|
|
|
368
452
|
|
|
369
453
|
| Provider | Kind | Auth | Model list | Notes |
|
|
370
454
|
|---|---|---|---|---|
|
|
371
|
-
| ChatGPT subscription | Codex backend | several sign-ins, switching when one runs out (Codex login reused too) |
|
|
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 % |
|
|
372
456
|
| OpenRouter | Anthropic-compatible | API key | 400+, discovered | per-model effort support read from the API |
|
|
373
457
|
| DeepSeek, Kimi, Z.ai GLM, MiniMax, Qwen (intl / cn) | Anthropic-compatible | API key | preset | verified against vendor docs |
|
|
374
458
|
| xAI Grok, Mistral, Groq, Together, Fireworks | OpenAI-compatible | API key | discovered | translated (Chat Completions / Responses) |
|
|
@@ -417,6 +501,9 @@ In daily use by the author, but young. Expect rough edges:
|
|
|
417
501
|
in, the picker and a live GPT call are verified on x64 hardware; crash recovery
|
|
418
502
|
on arm64. The window still has the stock Windows title bar. Windows builds are
|
|
419
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.
|
|
420
507
|
- ChatGPT, OpenRouter and Claude-in-Codex are verified with live accounts; the
|
|
421
508
|
other presets follow the vendors' official documentation.
|
|
422
509
|
- A model added to the picker is usable from the next session.
|
package/dist/cli/src/browser.js
CHANGED
|
@@ -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")];
|
package/dist/cli/src/index.js
CHANGED
|
@@ -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,
|
|
5
|
-
//
|
|
6
|
-
// then
|
|
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
|
-
:
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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
|
-
|
|
186
|
-
|
|
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)) {
|
package/dist/cli/src/picker.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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 (
|
|
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 (
|
|
176
|
+
if (isLinux)
|
|
177
|
+
return nssUntrust();
|
|
178
|
+
if (!caTrusted(caPem))
|
|
86
179
|
return false;
|
|
87
180
|
if (isWindows) {
|
|
88
181
|
try {
|
package/dist/cli/src/schtasks.js
CHANGED
|
@@ -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();
|
package/dist/cli/src/settings.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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
|
+
}
|
package/dist/router/src/admin.js
CHANGED
|
@@ -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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
.filter((d) => /^\d+\.\d+\.\d+$/.test(d))
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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.
|
package/dist/router/src/proxy.js
CHANGED
|
@@ -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.
|
|
8
|
+
export const VERSION = "0.5.0";
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
-
"description": "Use GPT and 400+ other models
|
|
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
|
-
"
|
|
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": {
|