pennyrouter 0.2.4 → 0.2.8
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 -21
- package/README.md +225 -185
- package/bin/penny.js +9 -9
- package/bin/pennyrouter.js +7 -7
- package/package.json +30 -30
- package/src/cli.js +1727 -1584
- package/src/harnesses/aider.js +48 -48
- package/src/harnesses/claude-code.js +318 -295
- package/src/harnesses/claude-code.test.js +57 -57
- package/src/harnesses/cline.js +77 -77
- package/src/harnesses/codegpt.js +76 -76
- package/src/harnesses/codex.js +180 -180
- package/src/harnesses/codex.test.js +119 -119
- package/src/harnesses/opencode.js +151 -151
- package/src/harnesses/shared.js +51 -51
- package/src/harnesses/windsurf.js +57 -57
- package/src/harnesses/zed.js +61 -61
- package/src/launch.js +467 -389
- package/src/launch.test.js +153 -153
- package/src/session.js +157 -157
- package/src/session.test.js +44 -44
- package/src/state.js +103 -103
- package/src/statusline.js +202 -202
- package/src/statusline.test.js +72 -72
package/src/session.test.js
CHANGED
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { createCliSession, probeClaudeCodeGateway } from "./session.js";
|
|
3
|
-
|
|
4
|
-
const originalFetch = globalThis.fetch;
|
|
5
|
-
|
|
6
|
-
try {
|
|
7
|
-
let sessionRequest;
|
|
8
|
-
globalThis.fetch = async (url, options) => {
|
|
9
|
-
sessionRequest = { url, options };
|
|
10
|
-
return {
|
|
11
|
-
ok: true,
|
|
12
|
-
json: async () => ({ session_id: "session-test" }),
|
|
13
|
-
};
|
|
14
|
-
};
|
|
15
|
-
await createCliSession({ appUrl: "https://app.example", harnesses: ["claude-code"] });
|
|
16
|
-
assert.equal(sessionRequest.url, "https://app.example/api/cli/session");
|
|
17
|
-
assert.equal(JSON.parse(sessionRequest.options.body).account_mode, "existing");
|
|
18
|
-
|
|
19
|
-
let seen;
|
|
20
|
-
globalThis.fetch = async (url, options) => {
|
|
21
|
-
seen = { url, options };
|
|
22
|
-
return { ok: true, status: 200 };
|
|
23
|
-
};
|
|
24
|
-
assert.equal(await probeClaudeCodeGateway({
|
|
25
|
-
gatewayBaseUrl: "https://gateway.example/", apiKey: "pr-test",
|
|
26
|
-
}), true);
|
|
27
|
-
assert.equal(seen.url, "https://gateway.example/v1/messages?beta=true");
|
|
28
|
-
assert.equal(seen.options.headers.authorization, "Bearer pr-test");
|
|
29
|
-
assert.equal(seen.options.headers["anthropic-beta"], "compact-2026-01-12");
|
|
30
|
-
const body = JSON.parse(seen.options.body);
|
|
31
|
-
assert.equal(body.max_tokens, 1);
|
|
32
|
-
assert.equal(body.context_management.edits[0].type, "compact_20260112");
|
|
33
|
-
assert.equal(body.tools[0].name, "pennyrouter_install_probe");
|
|
34
|
-
|
|
35
|
-
globalThis.fetch = async () => ({
|
|
36
|
-
ok: false, status: 502, text: async () => "context_management rejected",
|
|
37
|
-
});
|
|
38
|
-
await assert.rejects(
|
|
39
|
-
probeClaudeCodeGateway({ gatewayBaseUrl: "https://gateway.example", apiKey: "pr-test" }),
|
|
40
|
-
/HTTP 502.*No Claude Code settings were changed/,
|
|
41
|
-
);
|
|
42
|
-
} finally {
|
|
43
|
-
globalThis.fetch = originalFetch;
|
|
44
|
-
}
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createCliSession, probeClaudeCodeGateway } from "./session.js";
|
|
3
|
+
|
|
4
|
+
const originalFetch = globalThis.fetch;
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
let sessionRequest;
|
|
8
|
+
globalThis.fetch = async (url, options) => {
|
|
9
|
+
sessionRequest = { url, options };
|
|
10
|
+
return {
|
|
11
|
+
ok: true,
|
|
12
|
+
json: async () => ({ session_id: "session-test" }),
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
await createCliSession({ appUrl: "https://app.example", harnesses: ["claude-code"] });
|
|
16
|
+
assert.equal(sessionRequest.url, "https://app.example/api/cli/session");
|
|
17
|
+
assert.equal(JSON.parse(sessionRequest.options.body).account_mode, "existing");
|
|
18
|
+
|
|
19
|
+
let seen;
|
|
20
|
+
globalThis.fetch = async (url, options) => {
|
|
21
|
+
seen = { url, options };
|
|
22
|
+
return { ok: true, status: 200 };
|
|
23
|
+
};
|
|
24
|
+
assert.equal(await probeClaudeCodeGateway({
|
|
25
|
+
gatewayBaseUrl: "https://gateway.example/", apiKey: "pr-test",
|
|
26
|
+
}), true);
|
|
27
|
+
assert.equal(seen.url, "https://gateway.example/v1/messages?beta=true");
|
|
28
|
+
assert.equal(seen.options.headers.authorization, "Bearer pr-test");
|
|
29
|
+
assert.equal(seen.options.headers["anthropic-beta"], "compact-2026-01-12");
|
|
30
|
+
const body = JSON.parse(seen.options.body);
|
|
31
|
+
assert.equal(body.max_tokens, 1);
|
|
32
|
+
assert.equal(body.context_management.edits[0].type, "compact_20260112");
|
|
33
|
+
assert.equal(body.tools[0].name, "pennyrouter_install_probe");
|
|
34
|
+
|
|
35
|
+
globalThis.fetch = async () => ({
|
|
36
|
+
ok: false, status: 502, text: async () => "context_management rejected",
|
|
37
|
+
});
|
|
38
|
+
await assert.rejects(
|
|
39
|
+
probeClaudeCodeGateway({ gatewayBaseUrl: "https://gateway.example", apiKey: "pr-test" }),
|
|
40
|
+
/HTTP 502.*No Claude Code settings were changed/,
|
|
41
|
+
);
|
|
42
|
+
} finally {
|
|
43
|
+
globalThis.fetch = originalFetch;
|
|
44
|
+
}
|
package/src/state.js
CHANGED
|
@@ -1,103 +1,103 @@
|
|
|
1
|
-
import { constants } from "node:fs";
|
|
2
|
-
import { access, copyFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
-
|
|
5
|
-
export function homeDir() {
|
|
6
|
-
return process.env.HOME || process.env.USERPROFILE || "";
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function expandHome(path) {
|
|
10
|
-
if (path === "~") return homeDir();
|
|
11
|
-
// Accept both "~/..." and Windows "~\\..." — compactHome preserves whatever
|
|
12
|
-
// separator the platform's absolute path used, so a manifest written on Windows
|
|
13
|
-
// stores config paths as "~\\.claude\\settings.json". Matching only "~/" here
|
|
14
|
-
// let those fall through unexpanded, so read/write hit a literal "~" directory
|
|
15
|
-
// in the cwd instead of the real home. join() normalizes separators.
|
|
16
|
-
if (path.startsWith("~/") || path.startsWith("~\\")) return join(homeDir(), path.slice(2));
|
|
17
|
-
return path;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function compactHome(path) {
|
|
21
|
-
const home = homeDir();
|
|
22
|
-
return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function statePath(...parts) {
|
|
26
|
-
const base =
|
|
27
|
-
process.env.XDG_STATE_HOME ||
|
|
28
|
-
(process.platform === "darwin"
|
|
29
|
-
? join(homeDir(), ".local", "state")
|
|
30
|
-
: join(homeDir(), ".local", "state"));
|
|
31
|
-
return join(base, "pennyrouter", ...parts);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export async function exists(path) {
|
|
35
|
-
try {
|
|
36
|
-
await access(expandHome(path), constants.F_OK);
|
|
37
|
-
return true;
|
|
38
|
-
} catch {
|
|
39
|
-
return false;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export async function readText(path, fallback = "") {
|
|
44
|
-
try {
|
|
45
|
-
return await readFile(expandHome(path), "utf8");
|
|
46
|
-
} catch (error) {
|
|
47
|
-
if (error.code === "ENOENT") return fallback;
|
|
48
|
-
throw error;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export async function writeText(path, text) {
|
|
53
|
-
const expanded = expandHome(path);
|
|
54
|
-
await mkdir(dirname(expanded), { recursive: true });
|
|
55
|
-
await writeFile(expanded, text, "utf8");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export async function readJsonFile(path, fallback = null) {
|
|
59
|
-
const text = await readText(path, "");
|
|
60
|
-
if (!text.trim()) return fallback;
|
|
61
|
-
return JSON.parse(text);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export async function writeJsonFile(path, value) {
|
|
65
|
-
await writeText(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export async function loadManifest() {
|
|
69
|
-
return (await readJsonFile(statePath("installations.json"), null)) || {
|
|
70
|
-
version: 1,
|
|
71
|
-
installations: [],
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export async function saveManifest(manifest) {
|
|
76
|
-
await writeJsonFile(statePath("installations.json"), manifest);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export async function backupFile(path, harness) {
|
|
80
|
-
const expanded = expandHome(path);
|
|
81
|
-
if (!(await exists(expanded))) return null;
|
|
82
|
-
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "Z");
|
|
83
|
-
const ext = expanded.split(".").pop();
|
|
84
|
-
const backupPath = statePath("backups", `${harness}-${stamp}.${ext || "bak"}`);
|
|
85
|
-
await mkdir(dirname(backupPath), { recursive: true });
|
|
86
|
-
await copyFile(expanded, backupPath);
|
|
87
|
-
return backupPath;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export async function restoreFile(sourcePath, destinationPath) {
|
|
91
|
-
const source = expandHome(sourcePath);
|
|
92
|
-
const destination = expandHome(destinationPath);
|
|
93
|
-
await mkdir(dirname(destination), { recursive: true });
|
|
94
|
-
await copyFile(source, destination);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export async function atomicWrite(path, text) {
|
|
98
|
-
const expanded = expandHome(path);
|
|
99
|
-
await mkdir(dirname(expanded), { recursive: true });
|
|
100
|
-
const temp = `${expanded}.pennyrouter-tmp`;
|
|
101
|
-
await writeFile(temp, text, "utf8");
|
|
102
|
-
await rename(temp, expanded);
|
|
103
|
-
}
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, copyFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function homeDir() {
|
|
6
|
+
return process.env.HOME || process.env.USERPROFILE || "";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function expandHome(path) {
|
|
10
|
+
if (path === "~") return homeDir();
|
|
11
|
+
// Accept both "~/..." and Windows "~\\..." — compactHome preserves whatever
|
|
12
|
+
// separator the platform's absolute path used, so a manifest written on Windows
|
|
13
|
+
// stores config paths as "~\\.claude\\settings.json". Matching only "~/" here
|
|
14
|
+
// let those fall through unexpanded, so read/write hit a literal "~" directory
|
|
15
|
+
// in the cwd instead of the real home. join() normalizes separators.
|
|
16
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return join(homeDir(), path.slice(2));
|
|
17
|
+
return path;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function compactHome(path) {
|
|
21
|
+
const home = homeDir();
|
|
22
|
+
return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function statePath(...parts) {
|
|
26
|
+
const base =
|
|
27
|
+
process.env.XDG_STATE_HOME ||
|
|
28
|
+
(process.platform === "darwin"
|
|
29
|
+
? join(homeDir(), ".local", "state")
|
|
30
|
+
: join(homeDir(), ".local", "state"));
|
|
31
|
+
return join(base, "pennyrouter", ...parts);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function exists(path) {
|
|
35
|
+
try {
|
|
36
|
+
await access(expandHome(path), constants.F_OK);
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function readText(path, fallback = "") {
|
|
44
|
+
try {
|
|
45
|
+
return await readFile(expandHome(path), "utf8");
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error.code === "ENOENT") return fallback;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function writeText(path, text) {
|
|
53
|
+
const expanded = expandHome(path);
|
|
54
|
+
await mkdir(dirname(expanded), { recursive: true });
|
|
55
|
+
await writeFile(expanded, text, "utf8");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function readJsonFile(path, fallback = null) {
|
|
59
|
+
const text = await readText(path, "");
|
|
60
|
+
if (!text.trim()) return fallback;
|
|
61
|
+
return JSON.parse(text);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function writeJsonFile(path, value) {
|
|
65
|
+
await writeText(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function loadManifest() {
|
|
69
|
+
return (await readJsonFile(statePath("installations.json"), null)) || {
|
|
70
|
+
version: 1,
|
|
71
|
+
installations: [],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function saveManifest(manifest) {
|
|
76
|
+
await writeJsonFile(statePath("installations.json"), manifest);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function backupFile(path, harness) {
|
|
80
|
+
const expanded = expandHome(path);
|
|
81
|
+
if (!(await exists(expanded))) return null;
|
|
82
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "Z");
|
|
83
|
+
const ext = expanded.split(".").pop();
|
|
84
|
+
const backupPath = statePath("backups", `${harness}-${stamp}.${ext || "bak"}`);
|
|
85
|
+
await mkdir(dirname(backupPath), { recursive: true });
|
|
86
|
+
await copyFile(expanded, backupPath);
|
|
87
|
+
return backupPath;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function restoreFile(sourcePath, destinationPath) {
|
|
91
|
+
const source = expandHome(sourcePath);
|
|
92
|
+
const destination = expandHome(destinationPath);
|
|
93
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
94
|
+
await copyFile(source, destination);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function atomicWrite(path, text) {
|
|
98
|
+
const expanded = expandHome(path);
|
|
99
|
+
await mkdir(dirname(expanded), { recursive: true });
|
|
100
|
+
const temp = `${expanded}.pennyrouter-tmp`;
|
|
101
|
+
await writeFile(temp, text, "utf8");
|
|
102
|
+
await rename(temp, expanded);
|
|
103
|
+
}
|