@dondetir/ccmux 0.1.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/LICENSE +21 -0
- package/README.md +150 -0
- package/bin/ccmux.mjs +460 -0
- package/package.json +47 -0
- package/src/env.ts +20 -0
- package/src/index.ts +273 -0
- package/src/models.ts +285 -0
- package/src/native-stream.ts +51 -0
- package/src/responses-stream.ts +231 -0
- package/src/sessions.ts +66 -0
- package/src/stream.ts +172 -0
- package/src/token.ts +130 -0
- package/src/translate-request.ts +142 -0
- package/src/translate-response.ts +39 -0
- package/src/translate-responses.ts +252 -0
- package/src/types.ts +30 -0
- package/src/upstream.ts +107 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dondetir
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# ⚡ ccmux
|
|
4
|
+
|
|
5
|
+
**Claude Code on Copilot + Ollama Cloud.**
|
|
6
|
+
|
|
7
|
+
Your GitHub Copilot subscription (including the free tier) and your Ollama Cloud key both power `claude` in your terminal.
|
|
8
|
+
|
|
9
|
+
[](https://www.npmjs.com/package/ccmux)
|
|
10
|
+
[](https://nodejs.org)
|
|
11
|
+
[](./LICENSE)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm i -g ccmux && ccmux claude
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
*Don't switch tools. Switch backends.*
|
|
18
|
+
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
> 💡 **Why?** Your Copilot subscription already includes Claude, GPT, and Gemini, and Ollama Cloud adds GLM, Qwen, DeepSeek, Kimi and more. You just couldn't run them in `claude`. ccmux fixes that, aggregating every backend you have into one `/model` picker.
|
|
24
|
+
>
|
|
25
|
+
> ⚠️ **Unofficial:** it rides Copilot's editor-facing API, not a public one, so it can break when GitHub changes that surface. Free-tier premium requests are limited; `429`s are passed through so Claude Code backs off cleanly.
|
|
26
|
+
|
|
27
|
+
## 🔌 How it works
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
┌─────────────┐ Anthropic API ┌───────┐ Copilot API ┌────────────────┐
|
|
31
|
+
│ Claude Code │ -----------------> │ ccmux │ -----------------> │ GitHub Copilot │
|
|
32
|
+
│ (terminal) │ <----------------- │ :4141 │ <----------------- │ (your sub) │
|
|
33
|
+
└─────────────┘ streamed replies └───────┘ native/translate └────────────────┘
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`ccmux` speaks the Anthropic API to Claude Code and routes each request to the right backend by model id: native pass-through for Copilot's Claude models, OpenAI translation for GPT/Gemini and every Ollama Cloud model (`ollama/*`), and the Responses API for `/responses`-only models. Ollama thinking models stream their reasoning back as Claude thinking blocks. It binds to `127.0.0.1`, so your credentials never leave your machine.
|
|
37
|
+
|
|
38
|
+
## 🚀 Quickstart
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm i -g ccmux # puts the `ccmux` CLI on your PATH
|
|
42
|
+
ccmux claude # starts the proxy if needed, then launches Claude Code
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Requires **Node 20+**, [Claude Code](https://docs.anthropic.com/en/docs/claude-code), and a GitHub account with Copilot access. The first run without a token triggers a one-time device-flow login in your terminal (saved to `~/.config/ccmux/env`).
|
|
46
|
+
|
|
47
|
+
## 🧠 Models
|
|
48
|
+
|
|
49
|
+
Every Copilot model shows up in the native `/model` picker, with no `settings.json` edits:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
$ ccmux claude
|
|
53
|
+
✓ proxy ready on http://127.0.0.1:4141
|
|
54
|
+
> /model
|
|
55
|
+
claude-haiku-4.5
|
|
56
|
+
claude-sonnet-4.6
|
|
57
|
+
claude-gpt-4.1
|
|
58
|
+
claude-gemini-2.5-pro
|
|
59
|
+
…
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Free tier gets `claude-haiku-4.5` only; paid Copilot unlocks sonnet, GPT, and Gemini. Set an Ollama Cloud key (`ccmux login ollama`) and its models join the same picker as `ollama/<id>` (e.g. `ollama/glm-5.2`, `ollama/qwen3-coder:480b`), each showing its real context window. Non-Claude ids are prefixed `claude-` to pass discovery, then the proxy strips the prefix and routes them to the right backend.
|
|
63
|
+
|
|
64
|
+
## 🛠 Commands
|
|
65
|
+
|
|
66
|
+
| Command | What it does |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `ccmux claude` | Start the proxy if needed + launch Claude Code through it |
|
|
69
|
+
| `ccmux login copilot` | One-time GitHub device-flow login (in a terminal) |
|
|
70
|
+
| `ccmux login ollama` | Save an Ollama Cloud API key (from ollama.com/settings/keys) |
|
|
71
|
+
| `ccmux logout` | Remove saved tokens (GitHub + Ollama) + VS Code Copilot tokens + stop the proxy. Use `eval "$(ccmux logout)"` to also clear tokens exported in the current shell |
|
|
72
|
+
| `ccmux serve` | Run the proxy in the foreground on `:4141` |
|
|
73
|
+
|
|
74
|
+
Logs stream to `~/.config/ccmux/proxy.log`. If models vanish, the catalog self-heals on the next request.
|
|
75
|
+
|
|
76
|
+
## ⚙️ Configuration
|
|
77
|
+
|
|
78
|
+
Env vars (or `~/.config/ccmux/env`):
|
|
79
|
+
|
|
80
|
+
| Var | Default | Meaning |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `GH_TOKEN` | device flow | GitHub OAuth token (`GITHUB_TOKEN` also accepted) |
|
|
83
|
+
| `OLLAMA_API_KEY` | unset | Ollama Cloud key; set it (or run `ccmux login ollama`) to add `ollama/*` models |
|
|
84
|
+
| `NATIVE_MODEL` | `claude-haiku-4.5` | Model served to Claude Code. Paid: try `claude-sonnet-4.6` |
|
|
85
|
+
| `PORT` | `4141` | Proxy port (binds `127.0.0.1` only) |
|
|
86
|
+
| `USE_NATIVE` | `1` | `1` = native pass-through; `0` = OpenAI translation path |
|
|
87
|
+
| `CLAUDE_CONTEXT_WINDOW` | `200000` | Context window Claude Code assumes (token counts scaled to match) |
|
|
88
|
+
|
|
89
|
+
## 🔧 Development
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npm install
|
|
93
|
+
npm test # translators + streaming state machine
|
|
94
|
+
npm run dev # proxy with live reload
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## 📜 License
|
|
98
|
+
|
|
99
|
+
Released under the **MIT License**.
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
MIT License
|
|
103
|
+
|
|
104
|
+
Copyright (c) 2026 dondetir
|
|
105
|
+
|
|
106
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
107
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
108
|
+
in the Software without restriction, including without limitation the rights
|
|
109
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
110
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
111
|
+
furnished to do so, subject to the following conditions:
|
|
112
|
+
|
|
113
|
+
The above copyright notice and this permission notice shall be included in all
|
|
114
|
+
copies or substantial portions of the Software.
|
|
115
|
+
|
|
116
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
117
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
118
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
119
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
120
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
121
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
122
|
+
SOFTWARE.
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A copy is also included in the repository as [`LICENSE`](./LICENSE).
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
<div align="center">
|
|
130
|
+
|
|
131
|
+
### 💛 Built with conviction
|
|
132
|
+
|
|
133
|
+
Claude Code is the best agentic coding harness out there, but it only spoke one
|
|
134
|
+
backend. ccmux gives you the freedom to run *any* Copilot model on it, not just
|
|
135
|
+
Claude: GPT, Gemini, and Claude, all in one terminal, all from the subscription
|
|
136
|
+
you already have. No relay servers, no middlemen, just a few hundred lines of
|
|
137
|
+
TypeScript running on your own machine.
|
|
138
|
+
|
|
139
|
+
If ccmux gave you model freedom on the best harness, **⭐ star the repo**. It's
|
|
140
|
+
the fuel that keeps projects like this alive and maintained.
|
|
141
|
+
|
|
142
|
+
[](https://github.com/dondetir/ccmux)
|
|
143
|
+
·
|
|
144
|
+
[Report a bug](https://github.com/dondetir/ccmux/issues)
|
|
145
|
+
·
|
|
146
|
+
[Request a feature](https://github.com/dondetir/ccmux/issues)
|
|
147
|
+
|
|
148
|
+
*Built for developers who want every model on the best harness.*
|
|
149
|
+
|
|
150
|
+
</div>
|
package/bin/ccmux.mjs
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ccmux CLI launcher. Plain .mjs, no build step (tsx not required to run this file).
|
|
3
|
+
// `ccmux claude` starts the proxy if needed, gates on /v1/models (auth ready),
|
|
4
|
+
// and injects ANTHROPIC_BASE_URL + model discovery into the spawned session.
|
|
5
|
+
// No ~/.claude/settings.json edits needed or made.
|
|
6
|
+
|
|
7
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
8
|
+
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import net from "node:net";
|
|
10
|
+
import { createInterface } from "node:readline";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const ROOT = dirname(__dirname);
|
|
16
|
+
const PORT = Number(process.env.PORT ?? 4141);
|
|
17
|
+
const BASE = `http://127.0.0.1:${PORT}`;
|
|
18
|
+
const HOME = process.env.HOME || process.env.USERPROFILE;
|
|
19
|
+
const SESSIONS_DIR = join(HOME || "", ".config", "ccmux", "sessions");
|
|
20
|
+
const OLLAMA_BASE = process.env.OLLAMA_BASE ?? "https://ollama.com/v1";
|
|
21
|
+
const GH_COPILOT_DIR = join(HOME || "", ".config", "github-copilot");
|
|
22
|
+
// Mirrors what the proxy reads: token.ts checks apps.json and hosts.json;
|
|
23
|
+
// env.ts loads ./.env (cwd=ROOT) then ~/.config/ccmux/env. Regex is anchored
|
|
24
|
+
// to match env.ts's key parser (no leading whitespace allowed).
|
|
25
|
+
const ENV_FILES = [join(ROOT, ".env"), join(HOME || "", ".config", "ccmux", "env")];
|
|
26
|
+
function envFilesHave(re) {
|
|
27
|
+
return ENV_FILES.some((p) => {
|
|
28
|
+
try { return readFileSync(p, "utf8").split("\n").some((l) => re.test(l)); }
|
|
29
|
+
catch { return false; }
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const hasCopilotCreds = () =>
|
|
33
|
+
!!(process.env.GH_TOKEN || process.env.GITHUB_TOKEN) ||
|
|
34
|
+
existsSync(join(GH_COPILOT_DIR, "apps.json")) ||
|
|
35
|
+
existsSync(join(GH_COPILOT_DIR, "hosts.json")) ||
|
|
36
|
+
envFilesHave(/^(GH_TOKEN|GITHUB_TOKEN)=/);
|
|
37
|
+
const hasOllamaKey = () =>
|
|
38
|
+
!!process.env.OLLAMA_API_KEY || envFilesHave(/^OLLAMA_API_KEY=/);
|
|
39
|
+
|
|
40
|
+
const VERSION = (() => {
|
|
41
|
+
try { return JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version ?? "0.0.0"; }
|
|
42
|
+
catch { return "0.0.0"; }
|
|
43
|
+
})();
|
|
44
|
+
|
|
45
|
+
// ANSI helpers (degrade gracefully outside a TTY or when NO_COLOR is set).
|
|
46
|
+
const C = { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", cyan: "\x1b[36m", mag: "\x1b[35m" };
|
|
47
|
+
const HAS_COLOR = process.stdout.isTTY && !/^(0|false|no)$/i.test(process.env.NO_COLOR ?? "");
|
|
48
|
+
const paint = (code, t) => (HAS_COLOR ? `${code}${t}${C.reset}` : t);
|
|
49
|
+
|
|
50
|
+
function hero() {
|
|
51
|
+
const font = {
|
|
52
|
+
c: [" ███ ", "██ ", "██ ", " ███ "],
|
|
53
|
+
m: ["█ █", "██ ██", "█ █ █", "█ █"],
|
|
54
|
+
u: ["█ █", "█ █", "█ █", " ███ "],
|
|
55
|
+
x: ["█ █", " ██ ", " ██ ", "█ █"],
|
|
56
|
+
};
|
|
57
|
+
const word = ["c", "c", "m", "u", "x"];
|
|
58
|
+
const rows = [0, 1, 2, 3].map((r) => word.map((ch) => font[ch][r]).join(" "));
|
|
59
|
+
const ramp = [51, 45, 39, 99, 135, 171, 207, 213];
|
|
60
|
+
const gradRow = (r) => {
|
|
61
|
+
if (!HAS_COLOR) return r;
|
|
62
|
+
const W = r.length;
|
|
63
|
+
let out = "";
|
|
64
|
+
for (let i = 0; i < W; i++) {
|
|
65
|
+
const ch = r[i];
|
|
66
|
+
if (ch === " ") { out += " "; continue; }
|
|
67
|
+
const idx = Math.min(ramp.length - 1, Math.round((i / (W - 1)) * (ramp.length - 1)));
|
|
68
|
+
out += `\x1b[1;38;5;${ramp[idx]}m${ch}\x1b[0m`;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
};
|
|
72
|
+
const art = rows.map(gradRow).join("\n");
|
|
73
|
+
const tag = `${paint(C.bold, "Speaks Anthropic.")} ${paint(C.dim, "Routes to Copilot + Ollama.")}`;
|
|
74
|
+
const rule = "─".repeat(58);
|
|
75
|
+
// Right-align the version to the rule width so it sits in the top-right corner.
|
|
76
|
+
const ver = paint(C.cyan, "v" + VERSION);
|
|
77
|
+
const vraw = `v${VERSION}`;
|
|
78
|
+
const pad = Math.max(0, rule.length - vraw.length);
|
|
79
|
+
const verRow = " ".repeat(pad) + ver;
|
|
80
|
+
return `\n${verRow}\n${art}\n\n ${tag}\n${paint(C.dim, rule)}\n`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const USAGE = `${hero()}${paint(C.bold, "Usage:")} ccmux <command> [args]
|
|
84
|
+
|
|
85
|
+
${paint(C.cyan, "Commands:")}
|
|
86
|
+
claude [args...] Start the proxy (if needed) + launch Claude Code through it
|
|
87
|
+
serve Run the proxy in the foreground on http://127.0.0.1:${PORT}
|
|
88
|
+
login copilot GitHub device-flow login (saves token, auto-restarts proxy)
|
|
89
|
+
login ollama Paste (or set OLLAMA_API_KEY) an Ollama Cloud key, validate, auto-restart proxy
|
|
90
|
+
logout Remove saved tokens (GitHub + Ollama) + VS Code Copilot
|
|
91
|
+
tokens + stop the proxy. Use \`eval "$(ccmux logout)"\` to also
|
|
92
|
+
clear exported tokens in the current shell
|
|
93
|
+
help Show this help
|
|
94
|
+
|
|
95
|
+
${paint(C.mag, "Environment")} ${paint(C.dim, "(or ~/.config/ccmux/env):")}
|
|
96
|
+
NATIVE_MODEL Override the model served to Claude Code (default claude-haiku-4.5)
|
|
97
|
+
PORT Proxy port, localhost only (default ${PORT})
|
|
98
|
+
|
|
99
|
+
${paint(C.dim, "More env vars (USE_NATIVE, CLAUDE_CONTEXT_WINDOW, GH_TOKEN, OLLAMA_API_KEY): see README.")}
|
|
100
|
+
${paint(C.dim, "Logs stream to ~/.config/ccmux/proxy.log.")}`;
|
|
101
|
+
// Register this launcher PID so a MANAGED proxy can tell when no session is
|
|
102
|
+
// using it (startIdleReaper in sessions.ts checks this dir). Prune dead pids
|
|
103
|
+
// on entry so a killed launcher can't leave the dir growing indefinitely.
|
|
104
|
+
function registerSession() {
|
|
105
|
+
try {
|
|
106
|
+
mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 });
|
|
107
|
+
for (const f of readdirSync(SESSIONS_DIR)) {
|
|
108
|
+
const pid = Number(f);
|
|
109
|
+
let alive = false;
|
|
110
|
+
if (pid > 0) {
|
|
111
|
+
try { process.kill(pid, 0); alive = true; } catch {}
|
|
112
|
+
}
|
|
113
|
+
if (!alive) { try { rmSync(join(SESSIONS_DIR, f)); } catch {} }
|
|
114
|
+
}
|
|
115
|
+
writeFileSync(join(SESSIONS_DIR, String(process.pid)), "", { mode: 0o600 });
|
|
116
|
+
} catch (e) {
|
|
117
|
+
// Write failure means the reaper can't see this session and may
|
|
118
|
+
// self-stop the proxy mid-session, so surface rather than swallow.
|
|
119
|
+
console.warn("ccmux: failed to register session, proxy may self-stop:", e.message);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function deregisterSession() {
|
|
123
|
+
try { rmSync(join(SESSIONS_DIR, String(process.pid))); } catch {}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function proxyUp() {
|
|
127
|
+
return fetch(`${BASE}/v1/models`, { signal: AbortSignal.timeout(1500) })
|
|
128
|
+
.then((r) => r.ok)
|
|
129
|
+
.catch(() => false);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function waitForProxy(timeoutMs = 60000) {
|
|
133
|
+
const deadline = Date.now() + timeoutMs;
|
|
134
|
+
while (Date.now() < deadline) {
|
|
135
|
+
if (await proxyUp()) return true;
|
|
136
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function resolveProxyCommand() {
|
|
142
|
+
const tsx = join(ROOT, "node_modules", ".bin", "tsx");
|
|
143
|
+
return existsSync(tsx) ? { cmd: tsx, args: ["src/index.ts"] } : { cmd: "npx", args: ["tsx", "src/index.ts"] };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function startProxy() {
|
|
147
|
+
if (!HOME) {
|
|
148
|
+
console.error("ccmux: HOME (or USERPROFILE on Windows) must be set");
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
const logDir = join(HOME, ".config", "ccmux");
|
|
152
|
+
mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
153
|
+
chmodSync(logDir, 0o700); // ensure mode on existing dirs
|
|
154
|
+
const log = join(logDir, "proxy.log");
|
|
155
|
+
const { cmd, args } = resolveProxyCommand();
|
|
156
|
+
const fd = (() => {
|
|
157
|
+
try {
|
|
158
|
+
const f = openSync(log, "a", 0o600);
|
|
159
|
+
chmodSync(log, 0o600); // ensure mode despite umask
|
|
160
|
+
return f;
|
|
161
|
+
} catch {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
})();
|
|
165
|
+
const stdio = fd === undefined ? "ignore" : ["ignore", fd, fd];
|
|
166
|
+
const child = spawn(cmd, args, {
|
|
167
|
+
cwd: ROOT,
|
|
168
|
+
detached: true,
|
|
169
|
+
stdio,
|
|
170
|
+
// USE_NATIVE=1: default native pass-through; USE_NATIVE=0 opts out.
|
|
171
|
+
// CCMUX_MANAGED=1: proxy was booted for `ccmux claude` sessions; self-stops
|
|
172
|
+
// when none remain (startIdleReaper). `serve` does not set this flag.
|
|
173
|
+
env: { ...process.env, USE_NATIVE: process.env.USE_NATIVE ?? "1", CCMUX_MANAGED: "1" },
|
|
174
|
+
});
|
|
175
|
+
child.unref();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// TCP-listen check (lighter than proxyUp, which gates on Copilot auth).
|
|
179
|
+
// Confirms the server booted even when only Ollama is configured.
|
|
180
|
+
function portOpen(timeoutMs = 1000) {
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
const sock = net.connect({ host: "127.0.0.1", port: PORT });
|
|
183
|
+
const t = setTimeout(() => { sock.destroy(); resolve(false); }, timeoutMs);
|
|
184
|
+
sock.once("connect", () => { clearTimeout(t); sock.destroy(); resolve(true); });
|
|
185
|
+
sock.once("error", () => { clearTimeout(t); resolve(false); });
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function waitForPort(timeoutMs = 15000) {
|
|
190
|
+
const deadline = Date.now() + timeoutMs;
|
|
191
|
+
while (Date.now() < deadline) {
|
|
192
|
+
if (await portOpen()) return true;
|
|
193
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// -sTCP:LISTEN targets only the process holding the listen socket, never a
|
|
199
|
+
// connected client, so this can't accidentally kill an active Claude session.
|
|
200
|
+
function stopProxy() {
|
|
201
|
+
const lsofRes = spawnSync("lsof", ["-ti:" + PORT, "-sTCP:LISTEN"], { encoding: "utf8" });
|
|
202
|
+
if (lsofRes.error) {
|
|
203
|
+
console.warn("ccmux: lsof not found; couldn't stop a running proxy. Kill it manually if the port is taken.");
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
const pids = lsofRes.stdout?.trim();
|
|
207
|
+
let stopped = false;
|
|
208
|
+
if (pids) for (const pid of pids.split("\n")) { try { process.kill(Number(pid)); stopped = true; } catch {} }
|
|
209
|
+
return stopped;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Restart so a freshly saved credential takes effect immediately. The model
|
|
213
|
+
// catalog loads once at startup, so a new key isn't visible until restart.
|
|
214
|
+
async function restartProxy() {
|
|
215
|
+
const wasRunning = stopProxy();
|
|
216
|
+
if (wasRunning) await new Promise((r) => setTimeout(r, 800)); // let the old socket release
|
|
217
|
+
startProxy();
|
|
218
|
+
const up = await waitForPort();
|
|
219
|
+
if (up) console.log(`ccmux: proxy ${wasRunning ? "restarted" : "started"} on ${BASE}`);
|
|
220
|
+
else console.error("ccmux: proxy did not come up. Check ~/.config/ccmux/proxy.log");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function runProxyForeground() {
|
|
224
|
+
if (!HOME) {
|
|
225
|
+
console.error("ccmux: HOME (or USERPROFILE on Windows) must be set");
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
const { cmd, args } = resolveProxyCommand();
|
|
229
|
+
const child = spawn(cmd, args, {
|
|
230
|
+
cwd: ROOT,
|
|
231
|
+
stdio: "inherit",
|
|
232
|
+
env: { ...process.env, USE_NATIVE: process.env.USE_NATIVE ?? "1" },
|
|
233
|
+
});
|
|
234
|
+
child.once("close", (code) => process.exit(code ?? 0));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Run --login in the foreground (TTY attached) so the GitHub device flow can
|
|
238
|
+
// prompt. No-op when a token already resolves from env, .env, or VS Code config.
|
|
239
|
+
function runLogin() {
|
|
240
|
+
const { cmd, args } = resolveProxyCommand();
|
|
241
|
+
const child = spawn(cmd, [...args, "--login"], {
|
|
242
|
+
cwd: ROOT,
|
|
243
|
+
stdio: "inherit",
|
|
244
|
+
env: { ...process.env, USE_NATIVE: process.env.USE_NATIVE ?? "1" },
|
|
245
|
+
});
|
|
246
|
+
return new Promise((resolve) => child.once("close", (code) => resolve(code ?? 0)));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const OLLAMA_KEY_HELP = "Get one at https://ollama.com/settings/keys (sign in and copy an API key).";
|
|
250
|
+
|
|
251
|
+
// Validate key against /models before saving, so a typo is caught immediately.
|
|
252
|
+
// Returns model count (>0) when valid, else 0.
|
|
253
|
+
async function validateOllamaKey(key) {
|
|
254
|
+
try {
|
|
255
|
+
const res = await fetch(`${OLLAMA_BASE}/models`, { headers: { Authorization: `Bearer ${key}` } });
|
|
256
|
+
if (!res.ok) return 0;
|
|
257
|
+
const data = await res.json();
|
|
258
|
+
return (data?.data ?? []).length;
|
|
259
|
+
} catch {
|
|
260
|
+
return 0;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Ollama Cloud uses a static API key (no OAuth exchange). "login" saves the key
|
|
265
|
+
// to ~/.config/ccmux/env (0600) so it persists across sessions.
|
|
266
|
+
async function loginOllama() {
|
|
267
|
+
let key = process.env.OLLAMA_API_KEY;
|
|
268
|
+
if (!key) {
|
|
269
|
+
if (!process.stdin.isTTY) {
|
|
270
|
+
console.error("ccmux: no OLLAMA_API_KEY in env and no terminal to prompt.");
|
|
271
|
+
console.error(` ${OLLAMA_KEY_HELP}`);
|
|
272
|
+
console.error(" Run `ccmux login ollama` in a terminal, or export OLLAMA_API_KEY=sk-... first.");
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
console.log(`Ollama Cloud API key. ${OLLAMA_KEY_HELP}`);
|
|
276
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
277
|
+
rl._writeToOutput = () => {}; // mute echo so the pasted key isn't left in scrollback
|
|
278
|
+
process.stdout.write("Paste your Ollama Cloud API key: ");
|
|
279
|
+
key = (await new Promise((r) => rl.question("", r))).trim();
|
|
280
|
+
rl.close();
|
|
281
|
+
process.stdout.write("\n");
|
|
282
|
+
}
|
|
283
|
+
if (!key) { console.error("ccmux: no key entered."); console.error(` ${OLLAMA_KEY_HELP}`); process.exit(1); }
|
|
284
|
+
|
|
285
|
+
const n = await validateOllamaKey(key);
|
|
286
|
+
if (!n) {
|
|
287
|
+
console.error("ccmux: Ollama Cloud rejected that key (401/403 or returned no models).");
|
|
288
|
+
console.error(` ${OLLAMA_KEY_HELP}`);
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const dir = join(HOME || "", ".config", "ccmux");
|
|
293
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
294
|
+
chmodSync(dir, 0o700);
|
|
295
|
+
const p = join(dir, "env");
|
|
296
|
+
const kept = existsSync(p)
|
|
297
|
+
? readFileSync(p, "utf8").split("\n").filter((l) => l.trim() && !/^\s*OLLAMA_API_KEY=/.test(l))
|
|
298
|
+
: [];
|
|
299
|
+
kept.push(`OLLAMA_API_KEY=${key}`);
|
|
300
|
+
writeFileSync(p, kept.join("\n") + "\n", { mode: 0o600 });
|
|
301
|
+
chmodSync(p, 0o600);
|
|
302
|
+
console.log(`ccmux: saved OLLAMA_API_KEY to ${p} (key validated)`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Clear persisted credentials (env files + VS Code Copilot token files) and
|
|
306
|
+
// stop the proxy. Emits `unset ...` on stdout so `eval "$(ccmux logout)"` also
|
|
307
|
+
// clears tokens exported in the live shell. All human messages go to stderr so
|
|
308
|
+
// eval only picks up the unset line.
|
|
309
|
+
function logout() {
|
|
310
|
+
const warn = (m) => process.stderr.write(m + "\n");
|
|
311
|
+
// env.ts loads from ./.env (cwd=ROOT) then ~/.config/ccmux/env;
|
|
312
|
+
// token.ts honors GH_TOKEN and GITHUB_TOKEN. Clear both files.
|
|
313
|
+
let removedToken = false;
|
|
314
|
+
for (const envFile of [join(ROOT, ".env"), join(HOME || "", ".config", "ccmux", "env")]) {
|
|
315
|
+
if (!existsSync(envFile)) continue;
|
|
316
|
+
const lines = readFileSync(envFile, "utf8").split("\n");
|
|
317
|
+
const kept = lines.filter((l) => !/^\s*(GH_TOKEN|GITHUB_TOKEN|OLLAMA_API_KEY)=/.test(l));
|
|
318
|
+
if (kept.length === lines.length) continue;
|
|
319
|
+
removedToken = true;
|
|
320
|
+
const body = kept.join("\n").trim();
|
|
321
|
+
// writeFileSync mode is ignored on existing files; chmod enforces 0600.
|
|
322
|
+
if (body) { writeFileSync(envFile, body + "\n", { mode: 0o600 }); chmodSync(envFile, 0o600); }
|
|
323
|
+
else rmSync(envFile);
|
|
324
|
+
}
|
|
325
|
+
// VS Code Copilot stores OAuth tokens in apps.json + hosts.json; token.ts
|
|
326
|
+
// reads both. Leaving them means ccmux silently re-auths after logout.
|
|
327
|
+
let removedVscode = false;
|
|
328
|
+
for (const f of ["apps.json", "hosts.json"]) {
|
|
329
|
+
const p = join(GH_COPILOT_DIR, f);
|
|
330
|
+
try { if (existsSync(p)) { rmSync(p); removedVscode = true; } } catch (e) { warn(`ccmux: couldn't remove ${p}: ${e?.message ?? e}`); }
|
|
331
|
+
}
|
|
332
|
+
// -sTCP:LISTEN targets only the listener, not ESTABLISHED client connections.
|
|
333
|
+
let stopped = false;
|
|
334
|
+
const lsofRes = spawnSync("lsof", ["-ti:" + PORT, "-sTCP:LISTEN"], { encoding: "utf8" });
|
|
335
|
+
if (lsofRes.error)
|
|
336
|
+
warn("ccmux: lsof not found; couldn't stop the proxy. Kill it manually if it's still up.");
|
|
337
|
+
const pids = lsofRes.stdout?.trim();
|
|
338
|
+
if (pids) for (const pid of pids.split("\n")) {
|
|
339
|
+
try { process.kill(Number(pid)); stopped = true; } catch {}
|
|
340
|
+
}
|
|
341
|
+
warn(
|
|
342
|
+
`ccmux: logged out. ${removedToken ? "Removed saved tokens" : "No saved tokens on disk"}; ` +
|
|
343
|
+
`${stopped ? "stopped the running proxy" : "proxy was not running"}; ` +
|
|
344
|
+
`${removedVscode ? "removed VS Code Copilot tokens" : "no VS Code Copilot tokens present"}.`,
|
|
345
|
+
);
|
|
346
|
+
// A child process can't unset its parent shell's env; emit the command for
|
|
347
|
+
// `eval "$(ccmux logout)"` to apply. Plain `ccmux logout` just prints it.
|
|
348
|
+
process.stdout.write("unset GH_TOKEN GITHUB_TOKEN OLLAMA_API_KEY\n");
|
|
349
|
+
const shellEnvLeaked = !!(process.env.GH_TOKEN || process.env.GITHUB_TOKEN || process.env.OLLAMA_API_KEY);
|
|
350
|
+
if (shellEnvLeaked)
|
|
351
|
+
warn("ccmux: token(s) are still exported in this shell. Run `eval \"$(ccmux logout)\"` to clear them, then `ccmux login copilot`/`login ollama` to re-auth.");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function runClaude(args) {
|
|
355
|
+
const env = {
|
|
356
|
+
...process.env,
|
|
357
|
+
ANTHROPIC_BASE_URL: BASE,
|
|
358
|
+
ANTHROPIC_AUTH_TOKEN: "sk-dummy",
|
|
359
|
+
// Populate the /model picker from the proxy's /v1/models (incl. gpt/gemini,
|
|
360
|
+
// aliased as claude-* so they survive Claude Code's discovery filter).
|
|
361
|
+
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: process.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY ?? "1",
|
|
362
|
+
};
|
|
363
|
+
registerSession();
|
|
364
|
+
const child = spawn("claude", args, { stdio: "inherit", env });
|
|
365
|
+
child.once("close", (code) => {
|
|
366
|
+
deregisterSession();
|
|
367
|
+
process.exit(code ?? 0);
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function main() {
|
|
372
|
+
const cmd = process.argv[2];
|
|
373
|
+
const rest = process.argv.slice(3);
|
|
374
|
+
|
|
375
|
+
// Print and exit without starting a proxy (unknown cmds previously fell
|
|
376
|
+
// through to the proxy-up block and booted a stray proxy).
|
|
377
|
+
if (cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
378
|
+
console.log(USAGE);
|
|
379
|
+
process.exit(0);
|
|
380
|
+
}
|
|
381
|
+
if (cmd === "version" || cmd === "--version" || cmd === "-v") {
|
|
382
|
+
console.log(`ccmux v${VERSION}`);
|
|
383
|
+
process.exit(0);
|
|
384
|
+
}
|
|
385
|
+
const KNOWN = new Set(["claude", undefined, "serve", "login", "logout"]);
|
|
386
|
+
if (!KNOWN.has(cmd)) {
|
|
387
|
+
console.error(`ccmux: unknown command "${cmd ?? ""}"\n\n${USAGE}`);
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (cmd === "serve") {
|
|
392
|
+
if (await proxyUp()) {
|
|
393
|
+
console.error(`ccmux: proxy already running on ${BASE}`);
|
|
394
|
+
process.exit(0);
|
|
395
|
+
}
|
|
396
|
+
runProxyForeground();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// After any successful login, restart the proxy so the new credential is
|
|
401
|
+
// picked up immediately (catalogs load once at startup, not on the fly).
|
|
402
|
+
if (cmd === "login") {
|
|
403
|
+
const sub = rest[0];
|
|
404
|
+
if (sub === "ollama") {
|
|
405
|
+
await loginOllama();
|
|
406
|
+
await restartProxy();
|
|
407
|
+
process.exit(0);
|
|
408
|
+
}
|
|
409
|
+
if (sub === "copilot") {
|
|
410
|
+
const code = await runLogin();
|
|
411
|
+
if (code === 0) await restartProxy();
|
|
412
|
+
process.exit(code);
|
|
413
|
+
}
|
|
414
|
+
console.error(`ccmux login: specify a service: ccmux login copilot | ccmux login ollama`);
|
|
415
|
+
process.exit(1);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (cmd === "logout") {
|
|
419
|
+
logout();
|
|
420
|
+
process.exit(0);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (!(await proxyUp())) {
|
|
424
|
+
// Require at least one configured backend. Without this guard a machine
|
|
425
|
+
// with no creds would silently fall into the Copilot device flow.
|
|
426
|
+
const copilot = hasCopilotCreds();
|
|
427
|
+
if (!copilot && !hasOllamaKey()) {
|
|
428
|
+
console.error(
|
|
429
|
+
"ccmux: no backend configured. Run `ccmux login copilot` (GitHub Copilot) or `ccmux login ollama` (Ollama Cloud) first.",
|
|
430
|
+
);
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
// The proxy starts headless (no TTY), so the device flow can't prompt
|
|
434
|
+
// there. Run interactive login first when in a real terminal so the token
|
|
435
|
+
// is ready before boot. Skip for Ollama-only setups to avoid forcing a
|
|
436
|
+
// GitHub flow. Gates on stdout.isTTY to mirror token.ts behavior.
|
|
437
|
+
if (copilot && (cmd === "claude" || cmd === undefined) && process.stdout.isTTY) {
|
|
438
|
+
const code = await runLogin();
|
|
439
|
+
if (code !== 0) process.exit(code);
|
|
440
|
+
}
|
|
441
|
+
startProxy();
|
|
442
|
+
console.error(`ccmux: starting proxy on ${BASE}`);
|
|
443
|
+
const ok = await waitForProxy();
|
|
444
|
+
if (!ok) {
|
|
445
|
+
console.error(
|
|
446
|
+
"ccmux: proxy did not become ready in 60s. Run `ccmux login` to authenticate, or check ~/.config/ccmux/proxy.log",
|
|
447
|
+
);
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (cmd === "claude" || cmd === undefined) {
|
|
453
|
+
runClaude(rest);
|
|
454
|
+
} else {
|
|
455
|
+
console.error(`usage: ccmux [claude ...] | login [copilot|ollama] | logout | serve`);
|
|
456
|
+
process.exit(1);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dondetir/ccmux",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Run Claude Code on GitHub Copilot's backend, a local Anthropic-to-Copilot proxy",
|
|
5
|
+
"private": false,
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"bin": {
|
|
11
|
+
"ccmux": "bin/ccmux.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"src"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"dev": "tsx watch src/index.ts",
|
|
19
|
+
"start": "tsx src/index.ts",
|
|
20
|
+
"test": "vitest run"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "github:dondetir/ccmux"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/dondetir/ccmux",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/dondetir/ccmux/issues"
|
|
33
|
+
},
|
|
34
|
+
"overrides": {
|
|
35
|
+
"esbuild": "^0.28.1"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@hono/node-server": "^1.13.7",
|
|
39
|
+
"hono": "^4.6.14",
|
|
40
|
+
"tsx": "^4.19.2"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^20.17.10",
|
|
44
|
+
"typescript": "^5.7.2",
|
|
45
|
+
"vitest": "^2.1.8"
|
|
46
|
+
}
|
|
47
|
+
}
|