sandhop 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 +128 -0
- package/dist/agents/claude-code.js +178 -0
- package/dist/agents/claude-paths.js +36 -0
- package/dist/agents/codex.js +228 -0
- package/dist/agents/index.js +19 -0
- package/dist/agents/shared.js +7 -0
- package/dist/cli/args.js +82 -0
- package/dist/cli/config.js +34 -0
- package/dist/cli/enrich.js +50 -0
- package/dist/cli/host.js +7 -0
- package/dist/cli/install-command.js +35 -0
- package/dist/cli/main.js +110 -0
- package/dist/cli/setup.js +169 -0
- package/dist/core/encode.js +1 -0
- package/dist/core/env.js +5 -0
- package/dist/core/errors.js +11 -0
- package/dist/core/json.js +1 -0
- package/dist/core/manifest.js +12 -0
- package/dist/core/mcp-timeout.js +2 -0
- package/dist/core/paths.js +51 -0
- package/dist/core/ports/agent.js +1 -0
- package/dist/core/ports/host.js +1 -0
- package/dist/core/ports/provider.js +1 -0
- package/dist/core/ports/transport.js +1 -0
- package/dist/core/rand.js +6 -0
- package/dist/core/sandbox-scripts.js +54 -0
- package/dist/core/services/auth.js +11 -0
- package/dist/core/services/bootstrap.js +121 -0
- package/dist/core/services/enrichment.js +120 -0
- package/dist/core/services/mcp-classify.js +213 -0
- package/dist/core/services/mcp-code.js +78 -0
- package/dist/core/services/mcp-paths.js +43 -0
- package/dist/core/services/profile.js +50 -0
- package/dist/core/services/reinstall.js +159 -0
- package/dist/core/services/scripts.js +142 -0
- package/dist/core/services/secrets.js +68 -0
- package/dist/core/services/session.js +23 -0
- package/dist/core/services/teleport.js +71 -0
- package/dist/core/services/transfer.js +107 -0
- package/dist/core/services/version.js +14 -0
- package/dist/core/shell.js +14 -0
- package/dist/host/node.js +198 -0
- package/dist/index.js +20 -0
- package/dist/providers/daytona/index.js +97 -0
- package/dist/providers/destroy.js +11 -0
- package/dist/providers/e2b/index.js +93 -0
- package/dist/providers/encode.js +10 -0
- package/dist/providers/index.js +119 -0
- package/dist/providers/lazy-import.js +25 -0
- package/dist/providers/modal/index.js +110 -0
- package/dist/providers/vercel/index.js +121 -0
- package/dist/transports/cloudflared.js +42 -0
- package/dist/transports/public.js +13 -0
- package/docs/ARCHITECTURE.md +201 -0
- package/package.json +59 -0
- package/plugin/.claude-plugin/plugin.json +6 -0
- package/plugin/commands/sandhop.md +13 -0
- package/plugin/prompts/sandhop.md +7 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Talking Computers
|
|
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,128 @@
|
|
|
1
|
+
# Sandhop
|
|
2
|
+
|
|
3
|
+
Sandhop teleports a live local Claude Code or Codex session to a cloud sandbox and lets you continue it in an auth-gated browser terminal. It sends the dirty working tree, the active transcript, and the agent auth needed to resume the same session remotely — no commit, no context re-injection, native `--resume`.
|
|
4
|
+
|
|
5
|
+
## Why Sandhop
|
|
6
|
+
|
|
7
|
+
Claude on the web and Codex cloud are good for clean repo tasks. Sandhop is for the messy local moment: uncommitted files, generated state, local slash commands, MCP config, and either Claude Code or Codex. The wedge is dirty-tree + cross-tool teleport, not another hosted agent UI.
|
|
8
|
+
|
|
9
|
+
## Quickstart
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g sandhop
|
|
13
|
+
sandhop setup
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`sandhop setup` is a short wizard: pick your sandbox provider, paste its API key, choose a transport, and it installs the `/sandhop` command into whichever agents you have (Claude Code and/or Codex). Credentials are stored in `~/.config/sandhop/config.json` (mode 600). You are never asked for your Claude/Codex API key — that auth is captured from your existing local session at teleport time.
|
|
17
|
+
|
|
18
|
+
Then, from inside any Claude Code or Codex session in a project:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
/sandhop
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
It runs `sandhop push` for the current session and prints the web-terminal URL:
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
SANDHOP_URL https://<host>
|
|
28
|
+
SANDHOP_AUTH sandhop:<password>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Open `SANDHOP_URL` and sign in with the `SANDHOP_AUTH` user/password to continue your session in the browser.
|
|
32
|
+
|
|
33
|
+
## Sandbox providers
|
|
34
|
+
|
|
35
|
+
Sandhop is provider-agnostic. `sandhop setup` configures the default; override per-run with `--provider`.
|
|
36
|
+
|
|
37
|
+
| Provider | `--provider` | Credentials (collected by `sandhop setup`) |
|
|
38
|
+
| ------------------ | ------------ | ----------------------------------------------------- |
|
|
39
|
+
| **E2B** (default) | `e2b` | `E2B_API_KEY` |
|
|
40
|
+
| **Modal** | `modal` | `MODAL_TOKEN_ID`, `MODAL_TOKEN_SECRET` |
|
|
41
|
+
| **Daytona** | `daytona` | `DAYTONA_API_KEY` (+ optional `DAYTONA_TARGET`) |
|
|
42
|
+
| **Vercel Sandbox** | `vercel` | `VERCEL_TOKEN`, `VERCEL_TEAM_ID`, `VERCEL_PROJECT_ID` |
|
|
43
|
+
|
|
44
|
+
Each provider SDK is an optional dependency, loaded lazily — installing Sandhop does not pull all four. The CLI resolves credentials **environment first, then the `sandhop setup` store**, so CI/scripts can just export the env vars and skip setup.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
sandhop push # teleport the latest session in $(pwd)
|
|
50
|
+
sandhop push --provider modal # choose a provider for this run
|
|
51
|
+
sandhop push --tunnel cloudflared # private/portable URL (see below)
|
|
52
|
+
sandhop push --agent codex --session <id> # pin the agent and a specific session
|
|
53
|
+
sandhop push --no-profile # core only: working tree + transcript
|
|
54
|
+
sandhop list # list running sandboxes
|
|
55
|
+
sandhop kill <sandbox-id> # destroy a sandbox
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
(`sandhop` is the global bin; `node dist/cli/main.js <cmd>` is equivalent from source.)
|
|
59
|
+
|
|
60
|
+
### Flags
|
|
61
|
+
|
|
62
|
+
- `--provider e2b|modal|daytona|vercel` — sandbox provider (default: configured / `e2b`).
|
|
63
|
+
- `--tunnel public|cloudflared` — URL transport (default: configured / `public`).
|
|
64
|
+
- `--agent claude-code|codex` — force the agent (default: auto-detect from the cwd's sessions).
|
|
65
|
+
- `--session <id>` — resume a specific session instead of the newest for the cwd.
|
|
66
|
+
- `--no-profile` — skip profile/plugin/skill/MCP enrichment; the working tree + transcript still move.
|
|
67
|
+
- `--cwd <path>` — operate on a directory other than the process cwd.
|
|
68
|
+
|
|
69
|
+
## Transports / private access
|
|
70
|
+
|
|
71
|
+
- **`--tunnel public`** (default): exposes ttyd through the provider's HTTPS preview, gated by Sandhop's per-teleport ttyd Basic Auth.
|
|
72
|
+
- **`--tunnel cloudflared`**: binds ttyd to loopback and runs cloudflared inside the sandbox. Works through provider egress where native expose is token-gated (e.g. Daytona).
|
|
73
|
+
- _Quick tunnel_ (default): zero-config `*.trycloudflare.com` URL + Basic Auth.
|
|
74
|
+
- _Named tunnel_ (Access-gated, private): set `CLOUDFLARE_TUNNEL_TOKEN` + `CLOUDFLARE_TUNNEL_HOSTNAME` (or via `sandhop setup`); Sandhop returns `https://<your-hostname>` and Cloudflare Access enforces login.
|
|
75
|
+
|
|
76
|
+
## What transfers
|
|
77
|
+
|
|
78
|
+
- **Working tree** — full dirty/uncommitted state, restored at its original absolute path so the resumed session's recorded cwd matches.
|
|
79
|
+
- **Transcript** — the exact session file; resumed natively (`claude --resume` / `codex resume`), not re-injected.
|
|
80
|
+
- **Agent auth** — Claude/Codex credentials shipped as sandbox env/credential files over TLS, never inside the project tarball.
|
|
81
|
+
- **Profile** (enrichment) — settings, `CLAUDE.md`/`AGENTS.md`, commands, skills, plugins; plugins/skills are rebuilt from manifests/refs in-cloud (byte-equivalent versions) rather than bulk-uploaded.
|
|
82
|
+
- **MCP servers** — config + referenced env/secrets + local-path server code, with a raised startup timeout so `npx`-based servers finish installing. Servers that cannot run in a fresh sandbox (localhost/loopback DSNs, etc.) are excluded with a logged reason.
|
|
83
|
+
|
|
84
|
+
## How it works
|
|
85
|
+
|
|
86
|
+
1. **Fast core**: collect the working-tree root, transcript, auth, secrets, and local CLI version in parallel; create a single-tenant ephemeral sandbox; upload the bundle + transcript; install the matching agent CLI; restore the transcript; start ttyd; return the URL. Target: under ~2 minutes for ordinary projects.
|
|
87
|
+
2. **Detached enrichment**: transfer portable profile + MCP local code, then rebuild reproducible plugins/skills/deps from manifests so the URL stays fast.
|
|
88
|
+
|
|
89
|
+
## Security model
|
|
90
|
+
|
|
91
|
+
- Single-tenant ephemeral sandbox per push; auth/secrets travel as env/credential files over TLS, not in the tarball.
|
|
92
|
+
- Default access is HTTPS + per-teleport ttyd Basic Auth; `--tunnel cloudflared` adds quick-tunnel or Access-gated named-tunnel options.
|
|
93
|
+
- Sandhop never logs secret values.
|
|
94
|
+
- Destroy a sandbox with `sandhop kill <sandbox-id>`.
|
|
95
|
+
|
|
96
|
+
## Architecture
|
|
97
|
+
|
|
98
|
+
TypeScript modular monolith with a hexagonal core. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
99
|
+
|
|
100
|
+
- `src/core`: ports, pure data types, orchestration services (teleport, enrichment, snapshot, profile, secrets, MCP).
|
|
101
|
+
- `src/host`: local Node filesystem/process/keychain/tar adapter.
|
|
102
|
+
- `src/providers`: sandbox provider adapters — E2B, Modal, Daytona, Vercel — behind one `SandboxProvider` port + registry.
|
|
103
|
+
- `src/agents`: Claude Code and Codex adapters behind one `Agent` port.
|
|
104
|
+
- `src/transports`: `public` (provider URL) and `cloudflared` URL adapters.
|
|
105
|
+
- `src/cli`: composition root, `sandhop setup` wizard, and CLI entrypoints.
|
|
106
|
+
- `plugin/`: the `/sandhop` command (`commands/`) and prompt (`prompts/`) wrappers installed by `sandhop setup`.
|
|
107
|
+
|
|
108
|
+
## Limitations
|
|
109
|
+
|
|
110
|
+
- You need an existing local Claude Code or Codex session for the target cwd.
|
|
111
|
+
- Large dirty trees take time to archive and upload.
|
|
112
|
+
- Enrichment can finish after the terminal is usable; check `/tmp/sandhop-enrich.log` inside the sandbox.
|
|
113
|
+
- MCP servers that depend on local-only resources (localhost databases, local data files, a browser) or that require interactive OAuth (e.g. Notion, Ramp) won't function in a fresh sandbox — log in inside the sandbox, or expect them absent/unauthenticated.
|
|
114
|
+
- **Codex resume is org-bound**: it replays the session's encrypted reasoning to the API, so the shipped credential must belong to the org that created the rollout. The default `~/.codex/auth.json` ships as-is (fine for ordinary OpenAI logins). Sessions created under a custom provider profile (e.g. Azure) resume only with that provider's credential; ChatGPT-OAuth `auth.json` (no `OPENAI_API_KEY`) is unverified for cross-machine resume.
|
|
115
|
+
- The agent CLI installs at your exact local version, so Codex may show its standard "update available" notice — informational; choose "skip" to continue.
|
|
116
|
+
- Cloud rebuilds need network access to the git/npm/bun/uv sources referenced by your manifests.
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
npm ci
|
|
122
|
+
npm run build
|
|
123
|
+
npx vitest run
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
MIT © Talking Computers
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { projectDirName } from "../core/encode.js";
|
|
2
|
+
import { collectEnvRefs } from "../core/env.js";
|
|
3
|
+
import { isRecord } from "../core/json.js";
|
|
4
|
+
import { MCP_TIMEOUT_MS } from "../core/mcp-timeout.js";
|
|
5
|
+
import { basename } from "../core/paths.js";
|
|
6
|
+
import { buildClaudePreSeedScript } from "../core/sandbox-scripts.js";
|
|
7
|
+
import { makeVersionParser, sortNewest } from "./shared.js";
|
|
8
|
+
import { CLAUDE_JSON_HOME_PATH, CLAUDE_JSON_PATH, CLAUDE_MCP_PATH, CLAUDE_PROFILE_PATHS, CLAUDE_PROJECTS_PATH, CLAUDE_SETTINGS_LOCAL_PATH, CLAUDE_SETTINGS_PATH, joinClaudeHomePath, joinClaudeLocalPath, } from "./claude-paths.js";
|
|
9
|
+
const addJsonEnvRefs = (refs, value) => {
|
|
10
|
+
if (Array.isArray(value)) {
|
|
11
|
+
for (const item of value)
|
|
12
|
+
addJsonEnvRefs(refs, item);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (!isRecord(value))
|
|
16
|
+
return;
|
|
17
|
+
for (const [key, child] of Object.entries(value)) {
|
|
18
|
+
if (key === "env" && isRecord(child)) {
|
|
19
|
+
for (const name of Object.keys(child))
|
|
20
|
+
refs.add(name);
|
|
21
|
+
}
|
|
22
|
+
addJsonEnvRefs(refs, child);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const readJsonEnvRefs = (text) => {
|
|
26
|
+
const refs = new Set();
|
|
27
|
+
for (const name of collectEnvRefs(text))
|
|
28
|
+
refs.add(name);
|
|
29
|
+
try {
|
|
30
|
+
addJsonEnvRefs(refs, JSON.parse(text));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return [...refs].sort();
|
|
34
|
+
}
|
|
35
|
+
return [...refs].sort();
|
|
36
|
+
};
|
|
37
|
+
const parseVersion = makeVersionParser("claude-code");
|
|
38
|
+
const authEnv = (deps) => {
|
|
39
|
+
const envKey = deps.env.ANTHROPIC_API_KEY;
|
|
40
|
+
if (envKey !== undefined && envKey.startsWith("sk-ant-"))
|
|
41
|
+
return { envs: { ANTHROPIC_API_KEY: envKey }, files: [] };
|
|
42
|
+
const keychainKey = deps.keychain("Claude Code", null);
|
|
43
|
+
if (keychainKey !== null && keychainKey.startsWith("sk-ant-"))
|
|
44
|
+
return { envs: { ANTHROPIC_API_KEY: keychainKey }, files: [] };
|
|
45
|
+
const oauth = deps.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
46
|
+
if (oauth !== undefined)
|
|
47
|
+
return { envs: { CLAUDE_CODE_OAUTH_TOKEN: oauth }, files: [] };
|
|
48
|
+
throw new Error("No Claude Code credential. Run: claude setup-token, then export CLAUDE_CODE_OAUTH_TOKEN");
|
|
49
|
+
};
|
|
50
|
+
const readStringArray = (value) => {
|
|
51
|
+
if (!Array.isArray(value))
|
|
52
|
+
return undefined;
|
|
53
|
+
const values = [];
|
|
54
|
+
for (const item of value) {
|
|
55
|
+
if (typeof item !== "string")
|
|
56
|
+
return undefined;
|
|
57
|
+
values.push(item);
|
|
58
|
+
}
|
|
59
|
+
return values;
|
|
60
|
+
};
|
|
61
|
+
const readStringRecord = (value) => {
|
|
62
|
+
if (!isRecord(value))
|
|
63
|
+
return undefined;
|
|
64
|
+
const record = {};
|
|
65
|
+
for (const [key, item] of Object.entries(value)) {
|
|
66
|
+
if (typeof item !== "string")
|
|
67
|
+
return undefined;
|
|
68
|
+
record[key] = item;
|
|
69
|
+
}
|
|
70
|
+
return record;
|
|
71
|
+
};
|
|
72
|
+
const readTransport = (value, hasUrl) => {
|
|
73
|
+
if (value === "sse")
|
|
74
|
+
return "sse";
|
|
75
|
+
if (value === "http" || hasUrl)
|
|
76
|
+
return "http";
|
|
77
|
+
return "stdio";
|
|
78
|
+
};
|
|
79
|
+
const readMcpServers = (value, servers) => {
|
|
80
|
+
if (!isRecord(value))
|
|
81
|
+
return;
|
|
82
|
+
for (const [name, server] of Object.entries(value)) {
|
|
83
|
+
if (!isRecord(server))
|
|
84
|
+
continue;
|
|
85
|
+
const command = typeof server.command === "string" ? server.command : undefined;
|
|
86
|
+
const url = typeof server.url === "string" ? server.url : undefined;
|
|
87
|
+
if (command === undefined && url === undefined)
|
|
88
|
+
continue;
|
|
89
|
+
const args = readStringArray(server.args);
|
|
90
|
+
const env = readStringRecord(server.env);
|
|
91
|
+
const cwd = typeof server.cwd === "string" ? server.cwd : undefined;
|
|
92
|
+
servers.push({
|
|
93
|
+
name,
|
|
94
|
+
transport: readTransport(server.transport, url !== undefined),
|
|
95
|
+
...(command === undefined ? {} : { command }),
|
|
96
|
+
...(args === undefined ? {} : { args }),
|
|
97
|
+
...(env === undefined ? {} : { env }),
|
|
98
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
99
|
+
...(url === undefined ? {} : { url }),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const parseMcpServers = (deps, cwd) => {
|
|
104
|
+
const servers = [];
|
|
105
|
+
const claudeJson = deps.readFile(joinClaudeLocalPath(deps.home, CLAUDE_JSON_PATH));
|
|
106
|
+
if (claudeJson !== null) {
|
|
107
|
+
const parsed = JSON.parse(claudeJson);
|
|
108
|
+
if (isRecord(parsed)) {
|
|
109
|
+
readMcpServers(parsed.mcpServers, servers);
|
|
110
|
+
const projects = parsed.projects;
|
|
111
|
+
if (isRecord(projects)) {
|
|
112
|
+
const project = projects[cwd];
|
|
113
|
+
if (isRecord(project))
|
|
114
|
+
readMcpServers(project.mcpServers, servers);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const mcpJson = deps.readFile(`${cwd}/.mcp.json`);
|
|
119
|
+
if (mcpJson !== null) {
|
|
120
|
+
const parsed = JSON.parse(mcpJson);
|
|
121
|
+
if (isRecord(parsed))
|
|
122
|
+
readMcpServers(parsed.mcpServers, servers);
|
|
123
|
+
}
|
|
124
|
+
return servers;
|
|
125
|
+
};
|
|
126
|
+
const formatMcpConfig = (servers) => {
|
|
127
|
+
const mcpServers = {};
|
|
128
|
+
for (const server of servers) {
|
|
129
|
+
const { name, ...config } = server;
|
|
130
|
+
mcpServers[name] = config;
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
path: CLAUDE_JSON_HOME_PATH,
|
|
134
|
+
content: `${JSON.stringify(mcpServers, null, 2)}\n`,
|
|
135
|
+
mode: "merge-claude-json",
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
export const CLAUDE_CODE = {
|
|
139
|
+
id: "claude-code",
|
|
140
|
+
pkg: "@anthropic-ai/claude-code",
|
|
141
|
+
bin: "claude",
|
|
142
|
+
detectVersionArgs: ["--version"],
|
|
143
|
+
parseVersion,
|
|
144
|
+
matchSession: (deps, cwd) => {
|
|
145
|
+
const root = `${joinClaudeLocalPath(deps.home, CLAUDE_PROJECTS_PATH)}/${projectDirName(cwd)}`;
|
|
146
|
+
return sortNewest(deps, deps
|
|
147
|
+
.walk(root)
|
|
148
|
+
.filter((path) => path.endsWith(".jsonl"))
|
|
149
|
+
.map((path) => {
|
|
150
|
+
const name = basename(path);
|
|
151
|
+
return {
|
|
152
|
+
sessionId: name.replace(/\.jsonl$/, ""),
|
|
153
|
+
transcriptPath: path,
|
|
154
|
+
transcriptName: name,
|
|
155
|
+
};
|
|
156
|
+
}));
|
|
157
|
+
},
|
|
158
|
+
profilePaths: () => [...CLAUDE_PROFILE_PATHS],
|
|
159
|
+
mcpConfigPaths: (home, cwd) => [
|
|
160
|
+
`${cwd}/.mcp.json`,
|
|
161
|
+
joinClaudeLocalPath(home, CLAUDE_SETTINGS_PATH),
|
|
162
|
+
joinClaudeLocalPath(home, CLAUDE_SETTINGS_LOCAL_PATH),
|
|
163
|
+
joinClaudeLocalPath(home, CLAUDE_MCP_PATH),
|
|
164
|
+
joinClaudeLocalPath(home, CLAUDE_JSON_PATH),
|
|
165
|
+
],
|
|
166
|
+
mcpEnvRefs: readJsonEnvRefs,
|
|
167
|
+
parseMcpServers,
|
|
168
|
+
formatMcpConfig,
|
|
169
|
+
authEnv,
|
|
170
|
+
installCmd: (version) => `npm i -g @anthropic-ai/claude-code@${version}`,
|
|
171
|
+
supportsSettingsScripts: () => true,
|
|
172
|
+
supportsReinstall: () => true,
|
|
173
|
+
preSeed: (remoteProj) => [
|
|
174
|
+
`node -e ${JSON.stringify(buildClaudePreSeedScript(remoteProj))}`,
|
|
175
|
+
],
|
|
176
|
+
remoteTranscriptPath: (remoteEnc, transcriptName) => `${joinClaudeHomePath(CLAUDE_PROJECTS_PATH)}/${remoteEnc}/${transcriptName}`,
|
|
177
|
+
resumeCmd: (sessionId, remoteProj) => `cd "${remoteProj}" && MCP_TIMEOUT=${MCP_TIMEOUT_MS} claude --resume ${sessionId}`,
|
|
178
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const CLAUDE_JSON_PATH = ".claude.json";
|
|
2
|
+
export const CLAUDE_SETTINGS_PATH = ".claude/settings.json";
|
|
3
|
+
export const CLAUDE_SETTINGS_LOCAL_PATH = ".claude/settings.local.json";
|
|
4
|
+
export const CLAUDE_INSTRUCTIONS_PATH = ".claude/CLAUDE.md";
|
|
5
|
+
export const CLAUDE_COMMANDS_PATH = ".claude/commands";
|
|
6
|
+
export const CLAUDE_SKILLS_PATH = ".claude/skills";
|
|
7
|
+
export const CLAUDE_AGENTS_PATH = ".claude/agents";
|
|
8
|
+
export const CLAUDE_OUTPUT_STYLES_PATH = ".claude/output-styles";
|
|
9
|
+
export const CLAUDE_MCP_PATH = ".claude/mcp.json";
|
|
10
|
+
export const CLAUDE_PLUGINS_PATH = ".claude/plugins";
|
|
11
|
+
export const CLAUDE_PROJECTS_PATH = ".claude/projects";
|
|
12
|
+
export const CLAUDE_KNOWN_MARKETPLACES_PATH = ".claude/plugins/known_marketplaces.json";
|
|
13
|
+
export const CLAUDE_INSTALLED_PLUGINS_PATH = ".claude/plugins/installed_plugins.json";
|
|
14
|
+
export const CLAUDE_JSON_HOME_PATH = "$HOME/.claude.json";
|
|
15
|
+
export const CLAUDE_SETTINGS_SANDBOX_PATH = "/home/user/.claude/settings.json";
|
|
16
|
+
export const CLAUDE_PROFILE_PATHS = [
|
|
17
|
+
CLAUDE_SETTINGS_PATH,
|
|
18
|
+
CLAUDE_SETTINGS_LOCAL_PATH,
|
|
19
|
+
CLAUDE_INSTRUCTIONS_PATH,
|
|
20
|
+
CLAUDE_COMMANDS_PATH,
|
|
21
|
+
CLAUDE_SKILLS_PATH,
|
|
22
|
+
CLAUDE_AGENTS_PATH,
|
|
23
|
+
CLAUDE_OUTPUT_STYLES_PATH,
|
|
24
|
+
CLAUDE_MCP_PATH,
|
|
25
|
+
CLAUDE_PLUGINS_PATH,
|
|
26
|
+
];
|
|
27
|
+
export const CLAUDE_PROFILE_MANIFEST_PATHS = [
|
|
28
|
+
CLAUDE_SETTINGS_PATH,
|
|
29
|
+
CLAUDE_SETTINGS_LOCAL_PATH,
|
|
30
|
+
CLAUDE_INSTRUCTIONS_PATH,
|
|
31
|
+
CLAUDE_COMMANDS_PATH,
|
|
32
|
+
CLAUDE_KNOWN_MARKETPLACES_PATH,
|
|
33
|
+
CLAUDE_INSTALLED_PLUGINS_PATH,
|
|
34
|
+
];
|
|
35
|
+
export const joinClaudeHomePath = (relativePath) => `$HOME/${relativePath}`;
|
|
36
|
+
export const joinClaudeLocalPath = (localHome, relativePath) => `${localHome}/${relativePath}`;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { collectEnvRefs } from "../core/env.js";
|
|
2
|
+
import { MCP_STARTUP_TIMEOUT_SEC } from "../core/mcp-timeout.js";
|
|
3
|
+
import { buildCodexPreSeedScript } from "../core/sandbox-scripts.js";
|
|
4
|
+
import { basename } from "../core/paths.js";
|
|
5
|
+
import { parse, stringify } from "smol-toml";
|
|
6
|
+
import { makeVersionParser, sortNewest } from "./shared.js";
|
|
7
|
+
const codexId = (file) => {
|
|
8
|
+
const match = file.match(/rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)\.jsonl$/);
|
|
9
|
+
if (!match)
|
|
10
|
+
throw new Error(`Invalid Codex transcript filename ${file}`);
|
|
11
|
+
return match[1];
|
|
12
|
+
};
|
|
13
|
+
const parseVersion = makeVersionParser("codex");
|
|
14
|
+
const readRecordedCwd = (deps, path) => {
|
|
15
|
+
const text = deps.readFile(path);
|
|
16
|
+
if (text === null)
|
|
17
|
+
return null;
|
|
18
|
+
const first = text.split("\n", 1)[0];
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(first);
|
|
21
|
+
return typeof parsed.payload?.cwd === "string" ? parsed.payload.cwd : null;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const isTomlTable = (value) => typeof value === "object" &&
|
|
28
|
+
value !== null &&
|
|
29
|
+
!Array.isArray(value) &&
|
|
30
|
+
!(value instanceof Date);
|
|
31
|
+
const toTomlTable = (value, path) => {
|
|
32
|
+
if (value === undefined)
|
|
33
|
+
return undefined;
|
|
34
|
+
if (!isTomlTable(value))
|
|
35
|
+
throw new Error(`Expected ${path} to be a table`);
|
|
36
|
+
return value;
|
|
37
|
+
};
|
|
38
|
+
const toTomlString = (value, path) => {
|
|
39
|
+
if (value === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
if (typeof value !== "string")
|
|
42
|
+
throw new Error(`Expected ${path} to be a string`);
|
|
43
|
+
return value;
|
|
44
|
+
};
|
|
45
|
+
const toTomlStringArray = (value, path) => {
|
|
46
|
+
if (value === undefined)
|
|
47
|
+
return undefined;
|
|
48
|
+
if (!Array.isArray(value))
|
|
49
|
+
throw new Error(`Expected ${path} to be an array`);
|
|
50
|
+
return value.map((item, index) => {
|
|
51
|
+
if (typeof item !== "string")
|
|
52
|
+
throw new Error(`Expected ${path}[${index}] to be a string`);
|
|
53
|
+
return item;
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
const toTomlStringRecord = (value, path) => {
|
|
57
|
+
const table = toTomlTable(value, path);
|
|
58
|
+
if (table === undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
return Object.fromEntries(Object.entries(table).map(([key, field]) => {
|
|
61
|
+
if (typeof field !== "string")
|
|
62
|
+
throw new Error(`Expected ${path}.${key} to be a string`);
|
|
63
|
+
return [key, field];
|
|
64
|
+
}));
|
|
65
|
+
};
|
|
66
|
+
const collectEnvRefsFromValue = (refs, value) => {
|
|
67
|
+
if (typeof value === "string")
|
|
68
|
+
for (const name of collectEnvRefs(value))
|
|
69
|
+
refs.add(name);
|
|
70
|
+
else if (Array.isArray(value))
|
|
71
|
+
for (const item of value)
|
|
72
|
+
collectEnvRefsFromValue(refs, item);
|
|
73
|
+
else if (isTomlTable(value))
|
|
74
|
+
for (const item of Object.values(value))
|
|
75
|
+
collectEnvRefsFromValue(refs, item);
|
|
76
|
+
};
|
|
77
|
+
const readTomlEnvRefs = (text) => {
|
|
78
|
+
const refs = new Set();
|
|
79
|
+
const parsed = parse(text);
|
|
80
|
+
const mcpServers = toTomlTable(parsed.mcp_servers, "mcp_servers");
|
|
81
|
+
if (mcpServers !== undefined)
|
|
82
|
+
for (const [name, value] of Object.entries(mcpServers)) {
|
|
83
|
+
const server = toTomlTable(value, `mcp_servers.${name}`);
|
|
84
|
+
if (server === undefined)
|
|
85
|
+
throw new Error(`Expected mcp_servers.${name}`);
|
|
86
|
+
const env = toTomlTable(server.env, `mcp_servers.${name}.env`);
|
|
87
|
+
if (env !== undefined)
|
|
88
|
+
for (const key of Object.keys(env))
|
|
89
|
+
refs.add(key);
|
|
90
|
+
}
|
|
91
|
+
for (const value of Object.values(parsed))
|
|
92
|
+
collectEnvRefsFromValue(refs, value);
|
|
93
|
+
return [...refs].sort();
|
|
94
|
+
};
|
|
95
|
+
const toMcpServer = (name, value) => {
|
|
96
|
+
const table = toTomlTable(value, `mcp_servers.${name}`);
|
|
97
|
+
if (table === undefined)
|
|
98
|
+
throw new Error(`Expected mcp_servers.${name}`);
|
|
99
|
+
const command = toTomlString(table.command, `mcp_servers.${name}.command`);
|
|
100
|
+
const args = toTomlStringArray(table.args, `mcp_servers.${name}.args`);
|
|
101
|
+
const cwd = toTomlString(table.cwd, `mcp_servers.${name}.cwd`);
|
|
102
|
+
const url = toTomlString(table.url, `mcp_servers.${name}.url`);
|
|
103
|
+
const env = toTomlStringRecord(table.env, `mcp_servers.${name}.env`);
|
|
104
|
+
return {
|
|
105
|
+
name,
|
|
106
|
+
transport: url === undefined ? "stdio" : "http",
|
|
107
|
+
...(command === undefined ? {} : { command }),
|
|
108
|
+
...(args === undefined ? {} : { args }),
|
|
109
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
110
|
+
...(url === undefined ? {} : { url }),
|
|
111
|
+
...(env === undefined ? {} : { env }),
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
const parseMcpServers = (deps, cwd) => {
|
|
115
|
+
const files = [
|
|
116
|
+
`${deps.home}/.codex/config.toml`,
|
|
117
|
+
`${cwd}/.codex/config.toml`,
|
|
118
|
+
];
|
|
119
|
+
const servers = new Map();
|
|
120
|
+
for (const file of files) {
|
|
121
|
+
const text = deps.readFile(file);
|
|
122
|
+
if (text === null)
|
|
123
|
+
continue;
|
|
124
|
+
const parsed = parse(text);
|
|
125
|
+
const mcpServers = toTomlTable(parsed.mcp_servers, "mcp_servers");
|
|
126
|
+
if (mcpServers === undefined)
|
|
127
|
+
continue;
|
|
128
|
+
for (const [name, value] of Object.entries(mcpServers))
|
|
129
|
+
servers.set(name, toMcpServer(name, value));
|
|
130
|
+
}
|
|
131
|
+
return [...servers.values()].filter((server) => server.command !== undefined || server.url !== undefined);
|
|
132
|
+
};
|
|
133
|
+
const formatMcpConfig = (servers) => {
|
|
134
|
+
const mcpServers = {};
|
|
135
|
+
for (const server of servers) {
|
|
136
|
+
const table = { startup_timeout_sec: MCP_STARTUP_TIMEOUT_SEC };
|
|
137
|
+
if (server.command !== undefined)
|
|
138
|
+
table.command = server.command;
|
|
139
|
+
if (server.args !== undefined)
|
|
140
|
+
table.args = server.args;
|
|
141
|
+
if (server.cwd !== undefined)
|
|
142
|
+
table.cwd = server.cwd;
|
|
143
|
+
if (server.url !== undefined)
|
|
144
|
+
table.url = server.url;
|
|
145
|
+
if (server.env !== undefined)
|
|
146
|
+
table.env = server.env;
|
|
147
|
+
mcpServers[server.name] = table;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
path: "$HOME/.codex/config.toml",
|
|
151
|
+
content: `${stringify({ mcp_servers: mcpServers })}\n`,
|
|
152
|
+
mode: "append",
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
const authEnv = (deps) => {
|
|
156
|
+
const authJson = deps.readFile(`${deps.home}/.codex/auth.json`);
|
|
157
|
+
const envs = {};
|
|
158
|
+
const apiKey = deps.env.OPENAI_API_KEY;
|
|
159
|
+
if (apiKey !== undefined)
|
|
160
|
+
envs.OPENAI_API_KEY = apiKey;
|
|
161
|
+
const codexApiKey = deps.env.CODEX_API_KEY;
|
|
162
|
+
if (codexApiKey !== undefined)
|
|
163
|
+
envs.CODEX_API_KEY = codexApiKey;
|
|
164
|
+
if (authJson !== null && authJson.trim().length > 0)
|
|
165
|
+
return {
|
|
166
|
+
envs,
|
|
167
|
+
files: [{ path: "$HOME/.codex/auth.json", content: authJson }],
|
|
168
|
+
};
|
|
169
|
+
const codexHome = `${deps.home}/.codex`;
|
|
170
|
+
if (deps.exists(codexHome)) {
|
|
171
|
+
const account = `cli|${deps.sha256Hex(deps.realpath(codexHome)).slice(0, 16)}`;
|
|
172
|
+
const keychainJson = deps.keychain("Codex Auth", account);
|
|
173
|
+
if (keychainJson !== null && keychainJson.trim().length > 0)
|
|
174
|
+
return {
|
|
175
|
+
envs,
|
|
176
|
+
files: [{ path: "$HOME/.codex/auth.json", content: keychainJson }],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (Object.keys(envs).length > 0)
|
|
180
|
+
return { envs, files: [] };
|
|
181
|
+
throw new Error("No Codex credential at ~/.codex/auth.json, OS keychain, OPENAI_API_KEY, or CODEX_API_KEY");
|
|
182
|
+
};
|
|
183
|
+
export const CODEX = {
|
|
184
|
+
id: "codex",
|
|
185
|
+
pkg: "@openai/codex",
|
|
186
|
+
bin: "codex",
|
|
187
|
+
detectVersionArgs: ["--version"],
|
|
188
|
+
parseVersion,
|
|
189
|
+
matchSession: (deps, cwd) => {
|
|
190
|
+
const root = `${deps.home}/.codex/sessions`;
|
|
191
|
+
return sortNewest(deps, deps
|
|
192
|
+
.walk(root)
|
|
193
|
+
.filter((path) => /rollout-.*\.jsonl$/.test(path))
|
|
194
|
+
.filter((path) => readRecordedCwd(deps, path) === cwd)
|
|
195
|
+
.map((path) => {
|
|
196
|
+
const name = basename(path);
|
|
197
|
+
return {
|
|
198
|
+
sessionId: codexId(name),
|
|
199
|
+
transcriptPath: path,
|
|
200
|
+
transcriptName: name,
|
|
201
|
+
};
|
|
202
|
+
}));
|
|
203
|
+
},
|
|
204
|
+
profilePaths: () => [
|
|
205
|
+
".codex/config.toml",
|
|
206
|
+
".codex/AGENTS.md",
|
|
207
|
+
".codex/instructions.md",
|
|
208
|
+
".codex/prompts",
|
|
209
|
+
".codex/rules",
|
|
210
|
+
],
|
|
211
|
+
mcpConfigPaths: (home, cwd) => [
|
|
212
|
+
`${home}/.codex/config.toml`,
|
|
213
|
+
`${cwd}/.codex/config.toml`,
|
|
214
|
+
],
|
|
215
|
+
mcpEnvRefs: readTomlEnvRefs,
|
|
216
|
+
parseMcpServers,
|
|
217
|
+
formatMcpConfig,
|
|
218
|
+
authEnv,
|
|
219
|
+
installCmd: (version) => `npm i -g @openai/codex@${version}`,
|
|
220
|
+
supportsSettingsScripts: () => false,
|
|
221
|
+
supportsReinstall: () => false,
|
|
222
|
+
preSeed: (remoteProj) => [
|
|
223
|
+
"mkdir -p $HOME/.codex",
|
|
224
|
+
`node -e ${JSON.stringify(buildCodexPreSeedScript(remoteProj))}`,
|
|
225
|
+
],
|
|
226
|
+
remoteTranscriptPath: (remoteEnc, transcriptName) => `$HOME/.codex/sessions/restored/${transcriptName}`,
|
|
227
|
+
resumeCmd: (sessionId, remoteProj) => `cd "${remoteProj}" && codex resume ${sessionId}`,
|
|
228
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { CLAUDE_CODE } from "./claude-code.js";
|
|
2
|
+
import { CODEX } from "./codex.js";
|
|
3
|
+
export const AGENTS = [CLAUDE_CODE, CODEX];
|
|
4
|
+
export const detectAgents = (host, cwd) => AGENTS.filter((agent) => agent.matchSession(host, cwd).length > 0);
|
|
5
|
+
export const pickAgent = (id) => {
|
|
6
|
+
for (const agent of AGENTS)
|
|
7
|
+
if (agent.id === id)
|
|
8
|
+
return agent;
|
|
9
|
+
throw new Error(`Unknown agent ${id}`);
|
|
10
|
+
};
|
|
11
|
+
export const selectDefaultAgent = (agents) => {
|
|
12
|
+
const claude = agents.find((agent) => agent.id === "claude-code");
|
|
13
|
+
if (claude)
|
|
14
|
+
return claude;
|
|
15
|
+
const first = agents[0];
|
|
16
|
+
if (!first)
|
|
17
|
+
throw new Error("No Claude Code or Codex session found");
|
|
18
|
+
return first;
|
|
19
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const sortNewest = (deps, refs) => [...refs].sort((a, b) => deps.statMtimeMs(b.transcriptPath) - deps.statMtimeMs(a.transcriptPath));
|
|
2
|
+
export const makeVersionParser = (label) => (output) => {
|
|
3
|
+
const match = output.match(/(\d+\.\d+\.\d+)/);
|
|
4
|
+
if (!match)
|
|
5
|
+
throw new Error(`Could not parse ${label} version from "${output}"`);
|
|
6
|
+
return match[1];
|
|
7
|
+
};
|