codemax-cli 0.0.0-stage → 0.1.1
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 +661 -0
- package/NOTICE +27 -0
- package/README.md +150 -2
- package/TRADEMARKS.md +27 -0
- package/bin/codemax +2 -0
- package/bin/codemax.cjs +77 -0
- package/package.json +63 -4
- package/src/adapters/claude.ts +43 -0
- package/src/adapters/codex.ts +101 -0
- package/src/adapters/detect.ts +16 -0
- package/src/adapters/index.ts +21 -0
- package/src/adapters/simple.ts +89 -0
- package/src/adapters/types.ts +87 -0
- package/src/chat/app.ts +1422 -0
- package/src/chat/markdown.ts +77 -0
- package/src/chat/palette.ts +179 -0
- package/src/chat/permission.ts +111 -0
- package/src/chat/theme.ts +158 -0
- package/src/chat/transcript.ts +324 -0
- package/src/cli.ts +209 -0
- package/src/config.ts +96 -0
- package/src/core/brief.ts +314 -0
- package/src/core/budget.ts +42 -0
- package/src/core/checkpoint.ts +139 -0
- package/src/core/doctor.ts +67 -0
- package/src/core/files.ts +62 -0
- package/src/core/git.ts +36 -0
- package/src/core/hotkey.ts +75 -0
- package/src/core/journal.ts +213 -0
- package/src/core/ledger.ts +153 -0
- package/src/core/menu.ts +134 -0
- package/src/core/orchestrator.ts +352 -0
- package/src/core/paths.ts +30 -0
- package/src/core/prices-live.ts +123 -0
- package/src/core/pricing.ts +162 -0
- package/src/core/pty.ts +99 -0
- package/src/core/quota.ts +55 -0
- package/src/core/spawn.ts +20 -0
- package/src/core/terminal.ts +51 -0
- package/src/core/usage.ts +116 -0
- package/src/engines/acp.ts +419 -0
- package/src/engines/codex.ts +330 -0
- package/src/engines/env.ts +17 -0
- package/src/engines/errors.ts +47 -0
- package/src/engines/index.ts +154 -0
- package/src/engines/types.ts +131 -0
- package/src/transcripts/claude-usage.ts +63 -0
- package/src/transcripts/claude.ts +69 -0
- package/src/transcripts/codex.ts +74 -0
- package/src/tui/app.ts +377 -0
- package/src/tui/menu.ts +126 -0
- package/src/tui/pane.ts +79 -0
package/NOTICE
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
codemax
|
|
2
|
+
Copyright (C) 2026 Saidev Dhal <https://saidev.codes>
|
|
3
|
+
|
|
4
|
+
This program is free software: you can redistribute it and/or modify it under
|
|
5
|
+
the terms of the GNU Affero General Public License as published by the Free
|
|
6
|
+
Software Foundation, version 3 only (AGPL-3.0-only). See LICENSE.
|
|
7
|
+
|
|
8
|
+
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
9
|
+
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
|
10
|
+
PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
|
|
11
|
+
|
|
12
|
+
Additional permission under GNU AGPL version 3 section 7
|
|
13
|
+
|
|
14
|
+
codemax works by starting and talking to coding agents and their libraries,
|
|
15
|
+
some of which are distributed under their own non-free terms (for example
|
|
16
|
+
Claude Code and @anthropic-ai/claude-agent-sdk, Codex, Grok, Gemini CLI,
|
|
17
|
+
Cursor Agent and GitHub Copilot CLI). If you modify this program, or any
|
|
18
|
+
covered work, by linking or combining it with such an agent program or library
|
|
19
|
+
(or a modified version of it), the copyright holder grants you additional
|
|
20
|
+
permission to convey the resulting work. Corresponding Source for the
|
|
21
|
+
non-source form of such a combination shall not include the source code for
|
|
22
|
+
the parts of those agent programs or libraries that are not covered by this
|
|
23
|
+
License.
|
|
24
|
+
|
|
25
|
+
Trademarks
|
|
26
|
+
|
|
27
|
+
The AGPL covers the code, not the codemax name or logo. See TRADEMARKS.md.
|
package/README.md
CHANGED
|
@@ -1,3 +1,151 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/logo.svg" width="360" alt="codemax" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<p align="center"><b>Every AI coding plan you pay for. One terminal.</b></p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
Claude Code · Codex · OpenCode · Grok · Cursor · Gemini CLI
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## What you get
|
|
14
|
+
|
|
15
|
+
- **Never stop at a usage limit.** When one agent runs out, press Enter and the next one resumes the same task exactly where it stopped: same plan, the step that was in flight, and the real code changes so far.
|
|
16
|
+
- **One shared task across every agent.** Every step each agent takes is recorded, so a switch is a resume, not a summary. An agent you return to is caught up on what the others did.
|
|
17
|
+
- **Only the subscriptions you already pay for.** No API keys, no per-token bills, no new account.
|
|
18
|
+
- **Every agent asks before acting.** Edits and commands wait for your approval, just like in each agent's own app.
|
|
19
|
+
- **Undo any turn.** Roll back the files a reply changed with `/undo`.
|
|
20
|
+
- **See what you're getting.** `/usage` shows tokens and their API-equivalent value per agent, with daily budgets if you want them.
|
|
21
|
+
- **Free and open source.** Everything stays on your machine.
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
❯ fix the failing auth test
|
|
25
|
+
⏺ Fixed the off-by-one in token expiry. 14 tests pass.
|
|
26
|
+
⎿ Claude Code reached its daily limit · Enter to continue in Codex
|
|
27
|
+
───────────────────── Codex · gpt-5.6-terra · new session ─────────────────────
|
|
28
|
+
❯ now add a test for the refresh path
|
|
29
|
+
⏺ Added refresh-expiry coverage. 15 tests pass.
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Get started
|
|
33
|
+
|
|
34
|
+
**macOS and Linux:**
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
curl -fsSL https://codemax.saidev.codes/install | bash
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Windows** (PowerShell; Windows support is new):
|
|
41
|
+
|
|
42
|
+
```powershell
|
|
43
|
+
irm https://codemax.saidev.codes/install.ps1 | iex
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
It installs codemax (and [Bun](https://bun.sh) if you don't have it), then lists the agent CLIs it found. You need at least one agent you're logged in to. Then:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
codemax doctor # confirms nothing will bill an API key
|
|
50
|
+
cd ~/your-project && codemax
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
It installs the latest release. Run the same command again to update. To uninstall, delete `~/.codemax/cli` and `codemax` (or `codemax.cmd` on Windows) from `~/.bun/bin`.
|
|
54
|
+
|
|
55
|
+
<details>
|
|
56
|
+
<summary>Install from a clone instead</summary>
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
git clone https://github.com/SkidGod4444/codemax-cli.git
|
|
60
|
+
cd codemax-cli && bun install && bun link
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
</details>
|
|
64
|
+
|
|
65
|
+
## Supported agents
|
|
66
|
+
|
|
67
|
+
| Agent | Plan it uses | Status |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| Claude Code | Claude Pro / Max | Supported |
|
|
70
|
+
| Codex | ChatGPT Plus / Pro | Supported |
|
|
71
|
+
| OpenCode | Your OpenCode login | Supported |
|
|
72
|
+
| Grok | SuperGrok | Supported |
|
|
73
|
+
| Cursor Agent | Cursor plans | Supported (needs Cursor Agent 2026.10 or newer). Changing its model with `/model` also changes Cursor's own default. |
|
|
74
|
+
| Gemini CLI | Google AI plans | Early, untested |
|
|
75
|
+
| Copilot CLI | GitHub Copilot plans | In its own UI via `codemax --tui` |
|
|
76
|
+
|
|
77
|
+
## Everyday keys
|
|
78
|
+
|
|
79
|
+
| | |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `/switch` or **Ctrl+^** | Move to another agent, conversation included |
|
|
82
|
+
| `/ask codex <msg>` | One question to another agent |
|
|
83
|
+
| `@file` | Attach a file to your message |
|
|
84
|
+
| `/task` | The shared task: plan, who did what, where it stopped, files changed |
|
|
85
|
+
| `/undo` | Revert the last turn's changes |
|
|
86
|
+
| `/usage`, `/budget` | Usage and spend per agent |
|
|
87
|
+
| `/model`, `/effort`, **Shift+Tab** | Pick the model or reasoning effort, cycle permission mode |
|
|
88
|
+
| **Ctrl+O** | Expand cut-off tool output and thinking |
|
|
89
|
+
| Select with the mouse | Copies the text to your clipboard |
|
|
90
|
+
| `/theme dark`, `light`, `auto` | Colour theme (auto follows your terminal) |
|
|
91
|
+
| **Esc** | Stop the current turn |
|
|
92
|
+
| `/` | All commands |
|
|
93
|
+
|
|
94
|
+
<details>
|
|
95
|
+
<summary><b>Configuration</b></summary>
|
|
96
|
+
|
|
97
|
+
`~/.codemax/config.json` is created on first run. `codemax config` prints it.
|
|
98
|
+
|
|
99
|
+
| Key | Default | What it does |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| `order` | `["claude", "codex", "opencode", "cursor", "grok", "gemini", "copilot"]` | Which agent comes next on a handoff |
|
|
102
|
+
| `budgets` | `{}` | Daily limit per agent, e.g. `{"claude": 20, "all": 50}` |
|
|
103
|
+
| `routes` | `[]` | Send matching messages to a specific agent |
|
|
104
|
+
| `checkpoints` | `true` | Snapshots for `/undo` |
|
|
105
|
+
| `autoSwitch` | `true` | Offer a handoff when an agent runs out |
|
|
106
|
+
| `summarizeOnSwitch` | `true` | Ask the outgoing agent for a summary before a switch. Only used for history recorded before the step journal; otherwise the next agent resumes from the recorded steps |
|
|
107
|
+
| `priceUpdates` | `true` | Weekly price list refresh |
|
|
108
|
+
| `hotkeys` | `["ctrl+/", "ctrl+^"]` | Keys that open the agent switcher |
|
|
109
|
+
|
|
110
|
+
</details>
|
|
111
|
+
|
|
112
|
+
<details>
|
|
113
|
+
<summary><b>CLI commands</b></summary>
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
codemax [--agent <id>] [--fresh] start chatting
|
|
117
|
+
codemax doctor [--fix] check nothing bills an API key
|
|
118
|
+
codemax agents installed agents and their quota
|
|
119
|
+
codemax usage [--all] usage for this project or all projects
|
|
120
|
+
codemax prices [update] price list used for usage values
|
|
121
|
+
codemax history | brief | status | config | keys
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
</details>
|
|
125
|
+
|
|
126
|
+
<details>
|
|
127
|
+
<summary><b>Privacy and billing</b></summary>
|
|
128
|
+
|
|
129
|
+
- Your code goes only to the agents you already use, under your own logins.
|
|
130
|
+
- codemax always removes vendor API keys from the agents it starts, and `codemax doctor --fix` removes any left in Claude Code's settings.
|
|
131
|
+
- Grok follows your Claude Code permission setting. If that is `auto`, Grok acts without asking, and codemax warns you.
|
|
132
|
+
- Its only network call is a weekly price-list download, which `priceUpdates: false` turns off.
|
|
133
|
+
- Usage values are what your plans covered at public API rates, not money you spent.
|
|
134
|
+
|
|
135
|
+
</details>
|
|
136
|
+
|
|
137
|
+
## Contributing
|
|
138
|
+
|
|
139
|
+
Issues and pull requests are welcome. First-time contributors sign a short [CLA](CLA.md) on their pull request. See [CONTRIBUTING.md](CONTRIBUTING.md) and [PLAN.md](PLAN.md) for design notes.
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
bun test && bun run typecheck
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Not affiliated with Anthropic, OpenAI, xAI, Google or the OpenCode team.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
Copyright © 2026 [Saidev Dhal](https://saidev.codes). Free and open source under the [GNU AGPL-3.0](LICENSE), with an [added permission](NOTICE) for the agent tools codemax drives.
|
|
150
|
+
|
|
151
|
+
You can use, change and share codemax. If you distribute a modified version, or run one as a service, you must publish your changes under the same license. The codemax name and logo are not covered by the license: forks need their own name. See [TRADEMARKS.md](TRADEMARKS.md).
|
package/TRADEMARKS.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# codemax trademark policy
|
|
2
|
+
|
|
3
|
+
The codemax code is open source under the [AGPL-3.0](LICENSE). The **codemax name, the halftone wordmark and the "cm" icon** are not covered by that license. They identify the official project, and this policy says how you can use them.
|
|
4
|
+
|
|
5
|
+
## You can, without asking
|
|
6
|
+
|
|
7
|
+
- Say your project is **based on codemax** or **a fork of codemax**, or that it **works with codemax**.
|
|
8
|
+
- Use the name to talk about codemax accurately: in articles, talks, tutorials, comparisons and reviews.
|
|
9
|
+
- Keep the name in the code, file names and git history of an unmodified copy.
|
|
10
|
+
- Link to the official project with the name: https://github.com/SkidGod4444/codemax-cli or https://codemax.saidev.codes.
|
|
11
|
+
|
|
12
|
+
## You can't, without written permission
|
|
13
|
+
|
|
14
|
+
- Name a fork, product, package, command, domain or company **codemax**, or something confusingly close (for example "codemax pro", "codemaxx", "code-max").
|
|
15
|
+
- Use the codemax wordmark or icon for your own project, or a logo that looks like them.
|
|
16
|
+
- Suggest that your project is official, endorsed by or affiliated with codemax or Saidev Dhal.
|
|
17
|
+
- Publish a modified build under the codemax name on a package registry, app store or download page.
|
|
18
|
+
|
|
19
|
+
## If you fork
|
|
20
|
+
|
|
21
|
+
Give your fork its own name and logo before you publish it. Keep the copyright and license notices in `LICENSE` and `NOTICE`, as the AGPL requires. A line such as "based on codemax" is welcome.
|
|
22
|
+
|
|
23
|
+
## Questions
|
|
24
|
+
|
|
25
|
+
Ask before you're unsure: open an issue on the repository, or contact Saidev Dhal at https://saidev.codes.
|
|
26
|
+
|
|
27
|
+
This policy may be updated as the project grows.
|
package/bin/codemax
ADDED
package/bin/codemax.cjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codemax launcher for npm installs (`npm i -g codemax-cli`, `npx codemax-cli`).
|
|
3
|
+
// codemax runs on Bun; this finds a Bun binary and starts src/cli.ts with it:
|
|
4
|
+
// 1. the `bun` npm package installed alongside codemax (a dependency),
|
|
5
|
+
// 2. its platform package directly (when install scripts were skipped),
|
|
6
|
+
// 3. a `bun` already on PATH.
|
|
7
|
+
"use strict";
|
|
8
|
+
const { spawn, spawnSync } = require("node:child_process");
|
|
9
|
+
const { existsSync } = require("node:fs");
|
|
10
|
+
const { dirname, join } = require("node:path");
|
|
11
|
+
|
|
12
|
+
const cli = join(__dirname, "..", "src", "cli.ts");
|
|
13
|
+
const exe = process.platform === "win32" ? "bun.exe" : "bun";
|
|
14
|
+
|
|
15
|
+
function works(path) {
|
|
16
|
+
if (!path || !existsSync(path)) return false;
|
|
17
|
+
const r = spawnSync(path, ["--version"], { stdio: "ignore", timeout: 10000 });
|
|
18
|
+
return r.status === 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolveIn(pkg, ...rest) {
|
|
22
|
+
try {
|
|
23
|
+
return join(dirname(require.resolve(`${pkg}/package.json`)), ...rest);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isMusl() {
|
|
30
|
+
if (process.platform !== "linux") return false;
|
|
31
|
+
try {
|
|
32
|
+
const report = process.report && process.report.getReport();
|
|
33
|
+
return !(report && report.header && report.header.glibcVersionRuntime);
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function platformPackage() {
|
|
40
|
+
const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x64" : null;
|
|
41
|
+
if (!arch) return null;
|
|
42
|
+
if (process.platform === "darwin") return `@oven/bun-darwin-${arch}`;
|
|
43
|
+
if (process.platform === "win32") return `@oven/bun-windows-${arch}`;
|
|
44
|
+
if (process.platform === "linux") return `@oven/bun-linux-${arch}${isMusl() ? "-musl" : ""}`;
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findBun() {
|
|
49
|
+
const fromPackage = resolveIn("bun", "bin", "bun.exe");
|
|
50
|
+
if (works(fromPackage)) return fromPackage;
|
|
51
|
+
const pp = platformPackage();
|
|
52
|
+
const fromPlatform = pp && resolveIn(pp, "bin", exe);
|
|
53
|
+
if (works(fromPlatform)) return fromPlatform;
|
|
54
|
+
const which = spawnSync(process.platform === "win32" ? "where" : "which", ["bun"], { encoding: "utf8" });
|
|
55
|
+
const onPath = which.status === 0 ? which.stdout.split(/\r?\n/)[0].trim() : null;
|
|
56
|
+
if (works(onPath)) return onPath;
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const bun = findBun();
|
|
61
|
+
if (!bun) {
|
|
62
|
+
console.error("codemax: could not find Bun, which codemax runs on.");
|
|
63
|
+
console.error("Reinstall with `npm i -g codemax-cli` (it installs Bun), or install Bun from https://bun.sh");
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const child = spawn(bun, [cli, ...process.argv.slice(2)], { stdio: "inherit" });
|
|
68
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
69
|
+
process.on(sig, () => {
|
|
70
|
+
// The TUI handles Ctrl+C itself; forward only what the child would not see.
|
|
71
|
+
if (sig !== "SIGINT") child.kill(sig);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
child.on("exit", (code, signal) => {
|
|
75
|
+
if (signal) process.kill(process.pid, signal);
|
|
76
|
+
else process.exit(code ?? 0);
|
|
77
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codemax-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "One terminal for every coding agent you pay for: drive Claude Code, Codex, OpenCode, Grok and Gemini CLI from one chat, on your subscriptions, with context that survives switching agents.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"acp",
|
|
7
|
+
"agent-client-protocol",
|
|
8
|
+
"ai",
|
|
9
|
+
"claude-code",
|
|
10
|
+
"cli",
|
|
11
|
+
"codex",
|
|
12
|
+
"coding-agent",
|
|
13
|
+
"gemini-cli",
|
|
14
|
+
"grok",
|
|
15
|
+
"opencode",
|
|
16
|
+
"terminal",
|
|
17
|
+
"tui"
|
|
18
|
+
],
|
|
19
|
+
"homepage": "https://github.com/SkidGod4444/codemax-cli#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/SkidGod4444/codemax-cli/issues"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/SkidGod4444/codemax-cli.git"
|
|
26
|
+
},
|
|
27
|
+
"license": "AGPL-3.0-only",
|
|
28
|
+
"author": "Saidev Dhal",
|
|
29
|
+
"type": "module",
|
|
30
|
+
"bin": {
|
|
31
|
+
"codemax": "bin/codemax.cjs"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"bin",
|
|
35
|
+
"src",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"NOTICE",
|
|
39
|
+
"TRADEMARKS.md"
|
|
40
|
+
],
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18",
|
|
43
|
+
"bun": ">=1.3.0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"dev": "bun src/cli.ts",
|
|
47
|
+
"typecheck": "bun x tsc --noEmit",
|
|
48
|
+
"test": "bun test",
|
|
49
|
+
"build": "bun build --compile --minify src/cli.ts --outfile dist/codemax && (test \"$(uname)\" != Darwin || codesign --force --sign - dist/codemax)",
|
|
50
|
+
"prepublishOnly": "bun run typecheck && bun test"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@agentclientprotocol/sdk": "1.9.0",
|
|
54
|
+
"@opentui/core": "0.6.6",
|
|
55
|
+
"@zed-industries/claude-code-acp": "0.16.2",
|
|
56
|
+
"bun": "^1.3.5"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/bun": "latest",
|
|
60
|
+
"typescript": "^5.6.0"
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import type { Adapter, LaunchOptions, LaunchSpec, SessionRef } from "./types.ts";
|
|
4
|
+
import { HOME, escapeCwd } from "../core/paths.ts";
|
|
5
|
+
import { detectBin } from "./detect.ts";
|
|
6
|
+
import { parseClaudeTranscript } from "../transcripts/claude.ts";
|
|
7
|
+
|
|
8
|
+
function transcriptPath(cwd: string, id: string) {
|
|
9
|
+
return join(HOME, ".claude", "projects", escapeCwd(cwd), `${id}.jsonl`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const claude: Adapter = {
|
|
13
|
+
id: "claude",
|
|
14
|
+
name: "Claude Code",
|
|
15
|
+
bin: "claude",
|
|
16
|
+
detect: () => detectBin("claude", ["--version"]),
|
|
17
|
+
newSessionId: () => crypto.randomUUID(),
|
|
18
|
+
launch(o: LaunchOptions): LaunchSpec {
|
|
19
|
+
// Settings files can inject ANTHROPIC_API_KEY via their "env" block; blank it so the
|
|
20
|
+
// claude.ai login is billed. (The ACP engine path cannot pass this and is gated by doctor.)
|
|
21
|
+
const cmd = ["claude", "--settings", JSON.stringify({ env: { ANTHROPIC_API_KEY: "", ANTHROPIC_AUTH_TOKEN: "" } })];
|
|
22
|
+
if (o.resume) cmd.push("--resume", o.resume.id);
|
|
23
|
+
else if (o.sessionId) cmd.push("--session-id", o.sessionId);
|
|
24
|
+
if (o.extraArgs) cmd.push(...o.extraArgs);
|
|
25
|
+
// Interactive claude accepts an initial prompt as the positional argument.
|
|
26
|
+
if (o.prompt) cmd.push(o.prompt);
|
|
27
|
+
return { cmd, promptConsumed: !!o.prompt };
|
|
28
|
+
},
|
|
29
|
+
async locateSession({ cwd, startedAt, sessionId }) {
|
|
30
|
+
if (!sessionId) return null;
|
|
31
|
+
// The transcript appears after the first message; until then the session
|
|
32
|
+
// does not exist on Claude's side and could not be resumed.
|
|
33
|
+
const p = transcriptPath(cwd, sessionId);
|
|
34
|
+
if (!existsSync(p)) return null;
|
|
35
|
+
return { agent: "claude", id: sessionId, transcriptPath: p, cwd, startedAt, lastUsedAt: Date.now() };
|
|
36
|
+
},
|
|
37
|
+
async readTranscript(ref: SessionRef) {
|
|
38
|
+
const p = ref.transcriptPath ?? transcriptPath(ref.cwd, ref.id);
|
|
39
|
+
return parseClaudeTranscript(p, ref.id);
|
|
40
|
+
},
|
|
41
|
+
quotaPatterns: [/claude (ai )?usage limit reached/i, /out of extra usage/i, /limit will reset at/i],
|
|
42
|
+
apiKeyEnv: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
|
43
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { existsSync, readdirSync, statSync, openSync, readSync, closeSync } from "node:fs";
|
|
3
|
+
import type { Adapter, LaunchOptions, LaunchSpec, SessionRef } from "./types.ts";
|
|
4
|
+
import { HOME } from "../core/paths.ts";
|
|
5
|
+
import { detectBin } from "./detect.ts";
|
|
6
|
+
import { parseCodexTranscript, codexQuotaFromRollout } from "../transcripts/codex.ts";
|
|
7
|
+
|
|
8
|
+
const SESSIONS = join(HOME, ".codex", "sessions");
|
|
9
|
+
|
|
10
|
+
/** First line of a file. session_meta lines run to tens of KB, so read in chunks until a newline. */
|
|
11
|
+
function firstLine(path: string, max = 4 * 1024 * 1024): string {
|
|
12
|
+
const fd = openSync(path, "r");
|
|
13
|
+
try {
|
|
14
|
+
const chunks: Buffer[] = [];
|
|
15
|
+
let total = 0;
|
|
16
|
+
while (total < max) {
|
|
17
|
+
const buf = Buffer.alloc(65536);
|
|
18
|
+
const n = readSync(fd, buf, 0, buf.length, total);
|
|
19
|
+
if (n <= 0) break;
|
|
20
|
+
chunks.push(buf.subarray(0, n));
|
|
21
|
+
total += n;
|
|
22
|
+
if (buf.subarray(0, n).includes(0x0a)) break;
|
|
23
|
+
}
|
|
24
|
+
return Buffer.concat(chunks).toString("utf8").split("\n")[0] ?? "";
|
|
25
|
+
} finally {
|
|
26
|
+
closeSync(fd);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Rollout files written on/after `since`, newest first. Scans today and yesterday. */
|
|
31
|
+
function recentRollouts(since: number): string[] {
|
|
32
|
+
const out: string[] = [];
|
|
33
|
+
for (const dayOffset of [0, 1]) {
|
|
34
|
+
const d = new Date(since - dayOffset * 86_400_000);
|
|
35
|
+
const dir = join(SESSIONS, String(d.getFullYear()), String(d.getMonth() + 1).padStart(2, "0"), String(d.getDate()).padStart(2, "0"));
|
|
36
|
+
if (!existsSync(dir)) continue;
|
|
37
|
+
for (const f of readdirSync(dir)) {
|
|
38
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
39
|
+
const p = join(dir, f);
|
|
40
|
+
if (statSync(p).mtimeMs >= since - 2000) out.push(p);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function findRolloutById(id: string): string | undefined {
|
|
47
|
+
// Walk year/month/day dirs, newest first.
|
|
48
|
+
if (!existsSync(SESSIONS)) return undefined;
|
|
49
|
+
const years = readdirSync(SESSIONS).sort().reverse();
|
|
50
|
+
for (const y of years) {
|
|
51
|
+
const yd = join(SESSIONS, y);
|
|
52
|
+
for (const m of readdirSync(yd).sort().reverse()) {
|
|
53
|
+
const md = join(yd, m);
|
|
54
|
+
for (const d of readdirSync(md).sort().reverse()) {
|
|
55
|
+
const dd = join(md, d);
|
|
56
|
+
const hit = readdirSync(dd).find((f) => f.includes(id));
|
|
57
|
+
if (hit) return join(dd, hit);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const codex: Adapter = {
|
|
65
|
+
id: "codex",
|
|
66
|
+
name: "Codex",
|
|
67
|
+
bin: "codex",
|
|
68
|
+
detect: () => detectBin("codex", ["--version"]),
|
|
69
|
+
launch(o: LaunchOptions): LaunchSpec {
|
|
70
|
+
// Global flags go before the subcommand so `resume` accepts them too.
|
|
71
|
+
const cmd = ["codex", "-C", o.cwd];
|
|
72
|
+
if (o.resume) cmd.push("resume", o.resume.id);
|
|
73
|
+
if (o.extraArgs) cmd.push(...o.extraArgs);
|
|
74
|
+
if (o.prompt) cmd.push(o.prompt);
|
|
75
|
+
return { cmd, promptConsumed: !!o.prompt };
|
|
76
|
+
},
|
|
77
|
+
async locateSession({ cwd, startedAt }) {
|
|
78
|
+
for (const p of recentRollouts(startedAt)) {
|
|
79
|
+
try {
|
|
80
|
+
const meta = JSON.parse(firstLine(p));
|
|
81
|
+
if (meta.type === "session_meta" && meta.payload?.cwd === cwd) {
|
|
82
|
+
return { agent: "codex", id: meta.payload.id ?? meta.payload.session_id, transcriptPath: p, cwd, startedAt, lastUsedAt: Date.now() };
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
/* partial write, try next */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
},
|
|
90
|
+
async readTranscript(ref: SessionRef) {
|
|
91
|
+
const p = ref.transcriptPath ?? findRolloutById(ref.id);
|
|
92
|
+
return p ? parseCodexTranscript(p, ref.id) : [];
|
|
93
|
+
},
|
|
94
|
+
async probeQuota(ref) {
|
|
95
|
+
const p = ref?.transcriptPath ?? (ref ? findRolloutById(ref.id) : undefined);
|
|
96
|
+
if (!p) return null;
|
|
97
|
+
return codexQuotaFromRollout(p);
|
|
98
|
+
},
|
|
99
|
+
quotaPatterns: [/you['’]ve hit your usage limit/i, /usage limit reached/i],
|
|
100
|
+
apiKeyEnv: ["OPENAI_API_KEY"],
|
|
101
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DetectResult } from "./types.ts";
|
|
2
|
+
import { platformArgv } from "../core/spawn.ts";
|
|
3
|
+
|
|
4
|
+
export async function detectBin(bin: string, versionArgs: string[] = ["--version"]): Promise<DetectResult> {
|
|
5
|
+
const path = Bun.which(bin);
|
|
6
|
+
if (!path) return { installed: false };
|
|
7
|
+
try {
|
|
8
|
+
const p = Bun.spawn(platformArgv([path, ...versionArgs]), { stdout: "pipe", stderr: "pipe" });
|
|
9
|
+
const text = (await new Response(p.stdout).text()).trim();
|
|
10
|
+
await p.exited;
|
|
11
|
+
const m = text.match(/\d+\.\d+(\.\d+)?/);
|
|
12
|
+
return { installed: true, path, version: m?.[0] ?? text.split("\n")[0] };
|
|
13
|
+
} catch {
|
|
14
|
+
return { installed: true, path };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Adapter, AgentId } from "./types.ts";
|
|
2
|
+
import { claude } from "./claude.ts";
|
|
3
|
+
import { codex } from "./codex.ts";
|
|
4
|
+
import { opencode, cursor, grok, gemini, copilot, fake } from "./simple.ts";
|
|
5
|
+
|
|
6
|
+
const all: Adapter[] = [claude, codex, opencode, cursor, grok, gemini, copilot];
|
|
7
|
+
if (process.env.CODEMAX_FAKE_AGENT) all.push(fake);
|
|
8
|
+
|
|
9
|
+
export const adapters: Record<string, Adapter> = Object.fromEntries(all.map((a) => [a.id, a]));
|
|
10
|
+
|
|
11
|
+
export function getAdapter(id: string): Adapter {
|
|
12
|
+
const a = adapters[id];
|
|
13
|
+
if (!a) throw new Error(`unknown agent "${id}". Known: ${Object.keys(adapters).join(", ")}`);
|
|
14
|
+
return a;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function listAdapters(order: AgentId[]): Adapter[] {
|
|
18
|
+
const ranked = order.map((id) => adapters[id]).filter((a): a is Adapter => !!a);
|
|
19
|
+
const rest = all.filter((a) => !ranked.includes(a));
|
|
20
|
+
return [...ranked, ...rest];
|
|
21
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Adapter, AgentId, LaunchOptions, LaunchSpec } from "./types.ts";
|
|
2
|
+
import { detectBin } from "./detect.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Adapters for agents whose transcripts we do not parse yet (v0.2 work).
|
|
6
|
+
* They launch, pass through, and get the handoff brief typed into the TUI.
|
|
7
|
+
*/
|
|
8
|
+
function simple(opts: {
|
|
9
|
+
id: AgentId;
|
|
10
|
+
name: string;
|
|
11
|
+
bin: string;
|
|
12
|
+
versionArgs?: string[];
|
|
13
|
+
resumeArgs?: (id: string) => string[];
|
|
14
|
+
promptPositional?: boolean;
|
|
15
|
+
patterns?: RegExp[];
|
|
16
|
+
apiKeyEnv?: string[];
|
|
17
|
+
}): Adapter {
|
|
18
|
+
return {
|
|
19
|
+
id: opts.id,
|
|
20
|
+
name: opts.name,
|
|
21
|
+
bin: opts.bin,
|
|
22
|
+
detect: () => detectBin(opts.bin, opts.versionArgs),
|
|
23
|
+
launch(o: LaunchOptions): LaunchSpec {
|
|
24
|
+
const cmd = [opts.bin];
|
|
25
|
+
if (o.resume && opts.resumeArgs) cmd.push(...opts.resumeArgs(o.resume.id));
|
|
26
|
+
if (o.extraArgs) cmd.push(...o.extraArgs);
|
|
27
|
+
const consumed = !!o.prompt && !!opts.promptPositional;
|
|
28
|
+
if (consumed) cmd.push(o.prompt!);
|
|
29
|
+
return { cmd, promptConsumed: consumed };
|
|
30
|
+
},
|
|
31
|
+
async locateSession() {
|
|
32
|
+
return null;
|
|
33
|
+
},
|
|
34
|
+
quotaPatterns: opts.patterns ?? [],
|
|
35
|
+
apiKeyEnv: opts.apiKeyEnv ?? [],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const opencode = simple({
|
|
40
|
+
id: "opencode",
|
|
41
|
+
name: "OpenCode",
|
|
42
|
+
bin: "opencode",
|
|
43
|
+
// TUI positional is a project path, not a prompt → brief is typed in.
|
|
44
|
+
patterns: [/usage limit reached/i],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const cursor = simple({
|
|
48
|
+
id: "cursor",
|
|
49
|
+
name: "Cursor Agent",
|
|
50
|
+
bin: "cursor-agent",
|
|
51
|
+
resumeArgs: (id) => ["--resume", id],
|
|
52
|
+
promptPositional: true,
|
|
53
|
+
patterns: [/usage limit reached/i, /out of (fast )?requests/i],
|
|
54
|
+
apiKeyEnv: ["CURSOR_API_KEY"],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const grok = simple({
|
|
58
|
+
id: "grok",
|
|
59
|
+
name: "Grok CLI",
|
|
60
|
+
bin: "grok",
|
|
61
|
+
resumeArgs: (id) => ["--resume", id],
|
|
62
|
+
patterns: [/usage limit reached/i],
|
|
63
|
+
apiKeyEnv: ["XAI_API_KEY", "GROK_API_KEY"],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const gemini = simple({
|
|
67
|
+
id: "gemini",
|
|
68
|
+
name: "Gemini CLI",
|
|
69
|
+
bin: "gemini",
|
|
70
|
+
promptPositional: false,
|
|
71
|
+
patterns: [/quota exceeded/i, /RESOURCE_EXHAUSTED/],
|
|
72
|
+
apiKeyEnv: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export const copilot = simple({
|
|
76
|
+
id: "copilot",
|
|
77
|
+
name: "Copilot CLI",
|
|
78
|
+
bin: "copilot",
|
|
79
|
+
patterns: [/premium requests? (limit|exhausted|used up)/i],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** Test-only agent: a plain shell, enabled with CODEMAX_FAKE_AGENT=1. */
|
|
83
|
+
export const fake = simple({
|
|
84
|
+
id: "fake",
|
|
85
|
+
name: "Fake (sh)",
|
|
86
|
+
bin: "sh",
|
|
87
|
+
versionArgs: ["-c", "echo 0.0.0"],
|
|
88
|
+
patterns: [/FAKE_QUOTA_HIT/],
|
|
89
|
+
});
|