pennyrouter 0.3.2 → 0.3.4
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/README.md +34 -0
- package/bin/penny.js +13 -2
- package/package.json +2 -2
- package/src/cli.js +53 -40
- package/src/cli.test.js +76 -1
- package/src/install-manifest.json +128 -0
- package/src/launch.js +438 -4
- package/src/launch.test.js +93 -1
- package/src/statusline.js +0 -3
package/README.md
CHANGED
|
@@ -40,7 +40,14 @@ directory to the user PATH:
|
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
42
|
penny claude
|
|
43
|
+
penny claude desktop
|
|
44
|
+
penny claude desktop gateway
|
|
45
|
+
penny claude desktop native
|
|
46
|
+
penny claude desktop status
|
|
43
47
|
penny codex
|
|
48
|
+
penny codex desktop
|
|
49
|
+
penny codex desktop gateway
|
|
50
|
+
penny codex desktop native
|
|
44
51
|
penny opencode
|
|
45
52
|
```
|
|
46
53
|
|
|
@@ -51,6 +58,33 @@ completely native; their normal configuration files are not redirected. Choose t
|
|
|
51
58
|
"make PennyRouter the default" option, or pass `--make-default`, to retain the original global
|
|
52
59
|
routing behavior.
|
|
53
60
|
|
|
61
|
+
`penny codex desktop` launches Codex Desktop with a temporary `CODEX_HOME` overlay that selects
|
|
62
|
+
PennyRouter without changing the user's real Codex configuration. Auth, history, and other
|
|
63
|
+
Codex files remain linked to the normal home, and the overlay is removed when the app exits.
|
|
64
|
+
The command detaches by default and returns the shell prompt immediately; use
|
|
65
|
+
`penny codex desktop gateway --logs` to keep the desktop process and its logs attached to the
|
|
66
|
+
terminal. `penny codex desktop native` provides the symmetric explicit native launch used by the
|
|
67
|
+
PennyRouter Mac app; clicking the Codex icon directly remains native as well.
|
|
68
|
+
Launching Codex normally afterward therefore returns to the user's native provider.
|
|
69
|
+
|
|
70
|
+
`penny claude desktop` (or the explicit `gateway` subcommand) configures Claude Desktop's
|
|
71
|
+
official third-party Gateway provider and launches the app. `penny claude desktop native`
|
|
72
|
+
deactivates third-party inference for the next launch while preserving the Penny profile, and
|
|
73
|
+
`penny claude desktop status` reports the selected mode and whether the app is running. Claude
|
|
74
|
+
reads its provider once at startup, so Gateway and Native commands refuse to change modes until
|
|
75
|
+
Claude Desktop has been fully quit.
|
|
76
|
+
|
|
77
|
+
The integration does not intercept HTTPS traffic and does not modify terminal Claude Code's
|
|
78
|
+
`~/.claude/settings.json`. The generated Desktop profile contains no API key: its credential
|
|
79
|
+
helper reads the existing mode-0600 PennyRouter config when Claude requests a token. The profile
|
|
80
|
+
enables gateway model discovery, so the Desktop picker comes from PennyRouter's `/v1/models`
|
|
81
|
+
catalog rather than a launcher-maintained fixed list. Native Claude and third-party Claude keep
|
|
82
|
+
their own application data, so moving between them does not replace either mode's conversations.
|
|
83
|
+
PennyRouter also remembers the Desktop profile that was selected beforehand; use
|
|
84
|
+
`penny claude desktop --restore` for uninstall/recovery to remove the Penny profile and select
|
|
85
|
+
that previous third-party profile again. Launches detach by default; append `--logs` to a Gateway
|
|
86
|
+
or Native command to keep the app attached.
|
|
87
|
+
|
|
54
88
|
Existing PennyRouter installs preserve their current bare-command behavior during upgrade unless
|
|
55
89
|
the user explicitly changes it. Close running Claude Code or Codex sessions only when adding or
|
|
56
90
|
removing global bare-command routing. A Penny-only install does not require closing native sessions.
|
package/bin/penny.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
runClaudeDesktopCredentialHelper,
|
|
4
|
+
runCodexDesktopSupervisor,
|
|
5
|
+
runPenny,
|
|
6
|
+
} from "../src/launch.js";
|
|
3
7
|
|
|
4
|
-
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
const run = args[0] === "__codex-desktop-supervisor"
|
|
10
|
+
? runCodexDesktopSupervisor(args[1])
|
|
11
|
+
: args[0] === "__claude-desktop-credential-helper"
|
|
12
|
+
? runClaudeDesktopCredentialHelper()
|
|
13
|
+
: runPenny();
|
|
14
|
+
|
|
15
|
+
run.then((code) => {
|
|
5
16
|
process.exitCode = code;
|
|
6
17
|
}).catch((error) => {
|
|
7
18
|
console.error(error?.message || String(error));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pennyrouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Install and manage PennyRouter local coding-agent integrations.",
|
|
5
5
|
"homepage": "https://pennyrouter.com",
|
|
6
6
|
"bugs": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"start": "node ./bin/pennyrouter.js",
|
|
27
|
-
"check": "node --check ./bin/pennyrouter.js && node --check ./bin/penny.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node src/session.test.js && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js && node src/harnesses/opencode.test.js && node src/launch.test.js && node src/thread-session.test.js && node src/thread-session-codex.test.js && node src/mcp-register.test.js && node src/mcp.test.js"
|
|
27
|
+
"check": "node --check ./bin/pennyrouter.js && node --check ./bin/penny.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node --test src/cli.test.js && node src/session.test.js && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js && node src/harnesses/opencode.test.js && node src/launch.test.js && node src/thread-session.test.js && node src/thread-session-codex.test.js && node src/mcp-register.test.js && node src/mcp.test.js"
|
|
28
28
|
},
|
|
29
29
|
"license": "MIT"
|
|
30
30
|
}
|
package/src/cli.js
CHANGED
|
@@ -67,20 +67,30 @@ const HARNESS_BY_ID = {
|
|
|
67
67
|
aider: aiderHarness,
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
-
const SUPPORTED_HARNESS_IDS = Object.keys(HARNESS_BY_ID).join(",");
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
70
|
+
export const SUPPORTED_HARNESS_IDS = Object.keys(HARNESS_BY_ID).join(",");
|
|
71
|
+
|
|
72
|
+
// The GUI installer (apps/setup/swift) asks the install questions itself and hands the answers
|
|
73
|
+
// back through --config, so it needs to know the same harness ids and funding options this file
|
|
74
|
+
// branches on. It fetches this manifest from the published package over a CDN rather than
|
|
75
|
+
// duplicating the lists in Swift. Reading it here too is what keeps the two honest: there is one
|
|
76
|
+
// definition, and the code that acts on it is the code that reads it.
|
|
77
|
+
export const INSTALL_MANIFEST = JSON.parse(
|
|
78
|
+
readFileSync(new URL("./install-manifest.json", import.meta.url), "utf8"),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const DEFAULT_INSTALL_HARNESS_IDS = INSTALL_MANIFEST.default_harness_ids;
|
|
82
|
+
const CLAUDE_PROVIDER_OPTIONS = INSTALL_MANIFEST.funding_groups.claude.options
|
|
83
|
+
.map(({ id, name }) => ({ id, name }));
|
|
84
|
+
const CODEX_PROVIDER_OPTIONS = INSTALL_MANIFEST.funding_groups.codex.options
|
|
85
|
+
.map(({ id, name }) => ({ id, name }));
|
|
86
|
+
|
|
87
|
+
/** Harnesses the manifest gives a `penny <name>` launcher, keyed by id. */
|
|
88
|
+
const PENNY_COMMAND_BY_HARNESS = Object.fromEntries(
|
|
89
|
+
INSTALL_MANIFEST.harnesses
|
|
90
|
+
.filter((harness) => harness.penny_command)
|
|
91
|
+
.map((harness) => [harness.id, harness.penny_command]),
|
|
92
|
+
);
|
|
93
|
+
const LAUNCHABLE_HARNESS_IDS = Object.keys(PENNY_COMMAND_BY_HARNESS);
|
|
84
94
|
|
|
85
95
|
export async function main(argv = process.argv.slice(2)) {
|
|
86
96
|
const { command, flags } = parseArgs(argv);
|
|
@@ -225,7 +235,7 @@ function parseArgs(argv) {
|
|
|
225
235
|
// non-interactive paths unchanged. CLI flags win over the config file where both are given.
|
|
226
236
|
// Funding choices imply --yes (skip remaining prompts) but, unlike bare --yes, do NOT suppress
|
|
227
237
|
// the Claude subscription browser sign-in — see maybeConfigureClaudeAnthropicAuth.
|
|
228
|
-
function applyConfigFile(flags) {
|
|
238
|
+
export function applyConfigFile(flags) {
|
|
229
239
|
let config;
|
|
230
240
|
try {
|
|
231
241
|
config = JSON.parse(readFileSync(flags.configPath, "utf8"));
|
|
@@ -241,20 +251,27 @@ function applyConfigFile(flags) {
|
|
|
241
251
|
if (claudeFunding.has("subscription") && flags.anthropicAuth === undefined) {
|
|
242
252
|
flags.anthropicAuth = true;
|
|
243
253
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
254
|
+
// Every funding option the manifest describes carries the fields it needs, and their
|
|
255
|
+
// config_key is the flags field they populate. Copying them generically means a new option
|
|
256
|
+
// works here the moment it is added to the manifest, instead of needing a branch per option.
|
|
257
|
+
for (const [group, chosen] of [["claude", claudeFunding], ["codex", codexFunding]]) {
|
|
258
|
+
for (const option of INSTALL_MANIFEST.funding_groups[group].options) {
|
|
259
|
+
if (!chosen.has(option.id)) continue;
|
|
260
|
+
for (const field of option.fields) {
|
|
261
|
+
if (flags[field.config_key] === undefined) {
|
|
262
|
+
flags[field.config_key] = config[field.config_key] || "";
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
253
266
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
267
|
+
|
|
268
|
+
// A private gateway is independent of funding: it changes where the CLI claims its key and
|
|
269
|
+
// stores credentials, so it applies whatever providers were picked.
|
|
270
|
+
for (const field of INSTALL_MANIFEST.advanced.fields) {
|
|
271
|
+
const value = String(config[field.config_key] || "").trim();
|
|
272
|
+
if (value && flags[field.config_key] === undefined) flags[field.config_key] = value;
|
|
257
273
|
}
|
|
274
|
+
if (flags.gatewayBaseUrl) flags.privateGateway = true;
|
|
258
275
|
|
|
259
276
|
flags.yes = true;
|
|
260
277
|
flags.fromConfig = true;
|
|
@@ -535,9 +552,8 @@ export async function install(flags = {}) {
|
|
|
535
552
|
if (flags.dryRun) {
|
|
536
553
|
const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
|
|
537
554
|
for (const harness of selected) {
|
|
538
|
-
const
|
|
539
|
-
const
|
|
540
|
-
? "penny claude" : harness.id === "codex" ? "penny codex" : "penny opencode";
|
|
555
|
+
const command = PENNY_COMMAND_BY_HARNESS[harness.id];
|
|
556
|
+
const explicit = Boolean(command);
|
|
541
557
|
console.log(`Will install on ${harness.name}${explicit ? ` via ${command}` : installNoteForHarness(harness.id)}.`);
|
|
542
558
|
if (defaultRouting.get(harness.id)) {
|
|
543
559
|
console.log(`Will also route bare ${harness.id === "claude-code" ? "claude" : harness.id} through PennyRouter.`);
|
|
@@ -693,7 +709,7 @@ export async function install(flags = {}) {
|
|
|
693
709
|
// choose the visible command behavior with all relevant context in front of them.
|
|
694
710
|
const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
|
|
695
711
|
|
|
696
|
-
const launchable = selected.filter((harness) =>
|
|
712
|
+
const launchable = selected.filter((harness) => LAUNCHABLE_HARNESS_IDS.includes(harness.id));
|
|
697
713
|
let runtimeResult = null;
|
|
698
714
|
if (launchable.length > 0) {
|
|
699
715
|
runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
|
|
@@ -707,7 +723,7 @@ export async function install(flags = {}) {
|
|
|
707
723
|
const existingRecord = (manifest.installations || []).find(
|
|
708
724
|
(candidate) => candidate.harness === harness.id,
|
|
709
725
|
);
|
|
710
|
-
const supportsPennyCommand =
|
|
726
|
+
const supportsPennyCommand = LAUNCHABLE_HARNESS_IDS.includes(harness.id);
|
|
711
727
|
const makeDefault = defaultRouting.get(harness.id) ?? true;
|
|
712
728
|
const hadGlobalRouting = Boolean(existingRecord && existingRecord.default_routing !== false);
|
|
713
729
|
|
|
@@ -793,8 +809,7 @@ export async function install(flags = {}) {
|
|
|
793
809
|
for (const record of records) {
|
|
794
810
|
const harness = HARNESS_BY_ID[record.harness];
|
|
795
811
|
if (record.launch_available) {
|
|
796
|
-
const command = record.harness
|
|
797
|
-
? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
|
|
812
|
+
const command = PENNY_COMMAND_BY_HARNESS[record.harness];
|
|
798
813
|
const native = record.harness === "claude-code" ? "claude"
|
|
799
814
|
: record.harness === "codex" ? "codex" : "opencode";
|
|
800
815
|
const harnessName = record.harness === "claude-code" ? "Claude Code"
|
|
@@ -1047,7 +1062,7 @@ async function update(flags) {
|
|
|
1047
1062
|
|
|
1048
1063
|
const failures = [];
|
|
1049
1064
|
let runtimeResult = null;
|
|
1050
|
-
if (targets.some((record) =>
|
|
1065
|
+
if (targets.some((record) => LAUNCHABLE_HARNESS_IDS.includes(record.harness))) {
|
|
1051
1066
|
runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
|
|
1052
1067
|
}
|
|
1053
1068
|
|
|
@@ -1056,7 +1071,7 @@ async function update(flags) {
|
|
|
1056
1071
|
if (!harness) continue;
|
|
1057
1072
|
|
|
1058
1073
|
await runHarnessJob(failures, record.harness, "update", async () => {
|
|
1059
|
-
const supportsPennyCommand =
|
|
1074
|
+
const supportsPennyCommand = LAUNCHABLE_HARNESS_IDS.includes(record.harness);
|
|
1060
1075
|
// An update rewrites the harness's own config only when that config is ours to
|
|
1061
1076
|
// rewrite — i.e. the bare command still routes through the gateway. In penny-only
|
|
1062
1077
|
// mode the harness config belongs to the user and we touch just the launch profile.
|
|
@@ -1382,7 +1397,7 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
1382
1397
|
+ "or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
|
|
1383
1398
|
);
|
|
1384
1399
|
}
|
|
1385
|
-
console.log("Claude Code needs a user-funded provider.
|
|
1400
|
+
console.log("Claude Code needs a user-funded provider.");
|
|
1386
1401
|
anthropicApiKey = (await promptSecretish(
|
|
1387
1402
|
"Anthropic API key (leave blank to use OpenRouter): ",
|
|
1388
1403
|
)).trim();
|
|
@@ -1403,7 +1418,6 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
1403
1418
|
);
|
|
1404
1419
|
}
|
|
1405
1420
|
console.log("Codex is not logged into ChatGPT and needs a user-funded provider.");
|
|
1406
|
-
console.log("PennyRouter will never fall back to its own API keys.");
|
|
1407
1421
|
openaiApiKey = (await promptSecretish(
|
|
1408
1422
|
"OpenAI API key (leave blank to use OpenRouter): ",
|
|
1409
1423
|
)).trim();
|
|
@@ -1740,8 +1754,7 @@ async function status() {
|
|
|
1740
1754
|
console.log(` Upstream proxy: ${record.proxy_base_url}${note}`);
|
|
1741
1755
|
}
|
|
1742
1756
|
if (record.launch_available) {
|
|
1743
|
-
const command = record.harness
|
|
1744
|
-
? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
|
|
1757
|
+
const command = PENNY_COMMAND_BY_HARNESS[record.harness];
|
|
1745
1758
|
console.log(` command: ${command}`);
|
|
1746
1759
|
console.log(` bare command: ${record.default_routing ? "PennyRouter" : "native"}`);
|
|
1747
1760
|
}
|
package/src/cli.test.js
CHANGED
|
@@ -3,7 +3,9 @@ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
applyConfigFile, harnessFundingChanged, INSTALL_MANIFEST, main, SUPPORTED_HARNESS_IDS,
|
|
8
|
+
} from "./cli.js";
|
|
7
9
|
|
|
8
10
|
// --config lets a GUI installer (the macOS DMG) answer install questions up front instead of
|
|
9
11
|
// over a TTY. These tests drive it through `main()` in --dry-run, which prints its plan and
|
|
@@ -194,3 +196,76 @@ test("auth anthropic --forget honors --local", async () => {
|
|
|
194
196
|
]));
|
|
195
197
|
assert.ok(lines.some((line) => line.includes("localhost:8400")));
|
|
196
198
|
});
|
|
199
|
+
|
|
200
|
+
// --- Manifest contract -------------------------------------------------------------------
|
|
201
|
+
// The GUI installer (apps/setup/swift) builds its whole question flow from install-manifest.json,
|
|
202
|
+
// fetched from the published package. These tests pin the parts Swift depends on and, more
|
|
203
|
+
// importantly, prove the manifest still describes what this file actually does — a funding option
|
|
204
|
+
// the GUI can offer but --config cannot apply is the exact drift the manifest exists to prevent.
|
|
205
|
+
|
|
206
|
+
test("every manifest funding field is applied by --config", () => {
|
|
207
|
+
// Asserts on applyConfigFile directly rather than through a --dry-run install: the dry run
|
|
208
|
+
// returns before configureUserFunding, so it cannot observe a field that was never copied.
|
|
209
|
+
for (const [group, configKey] of [["claude", "claudeFunding"], ["codex", "codexFunding"]]) {
|
|
210
|
+
for (const option of INSTALL_MANIFEST.funding_groups[group].options) {
|
|
211
|
+
if (option.fields.length === 0) continue;
|
|
212
|
+
const config = { harnesses: ["claude-code", "codex"], [configKey]: [option.id] };
|
|
213
|
+
for (const field of option.fields) config[field.config_key] = `value-${field.config_key}`;
|
|
214
|
+
withConfigFile(config, (path) => {
|
|
215
|
+
const flags = { configPath: path };
|
|
216
|
+
applyConfigFile(flags);
|
|
217
|
+
for (const field of option.fields) {
|
|
218
|
+
assert.equal(
|
|
219
|
+
flags[field.config_key], `value-${field.config_key}`,
|
|
220
|
+
`${group}/${option.id}: --config ignored ${field.config_key}`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("manifest harness ids are all installable", () => {
|
|
229
|
+
const supported = SUPPORTED_HARNESS_IDS.split(",");
|
|
230
|
+
for (const harness of INSTALL_MANIFEST.harnesses) {
|
|
231
|
+
assert.ok(supported.includes(harness.id), `${harness.id} is not a supported harness`);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("manifest config paths match what each harness detects", () => {
|
|
236
|
+
// Swift decides which tools to pre-check by testing these paths itself, so a path that drifts
|
|
237
|
+
// from the harness module makes the GUI silently under-detect an installed tool.
|
|
238
|
+
const expected = {
|
|
239
|
+
"claude-code": ["~/.claude/settings.json"],
|
|
240
|
+
codex: ["~/.codex/config.toml"],
|
|
241
|
+
opencode: ["~/.config/opencode/opencode.jsonc", "~/.config/opencode/opencode.json"],
|
|
242
|
+
};
|
|
243
|
+
for (const harness of INSTALL_MANIFEST.harnesses) {
|
|
244
|
+
assert.deepEqual(harness.config_paths, expected[harness.id], `${harness.id} config paths`);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("--config applies a private gateway and its key", async () => {
|
|
249
|
+
await withConfigFile(
|
|
250
|
+
{
|
|
251
|
+
harnesses: ["claude-code"],
|
|
252
|
+
claudeFunding: ["subscription"],
|
|
253
|
+
gatewayBaseUrl: "http://localhost:8400",
|
|
254
|
+
privateKey: "pr-test-private",
|
|
255
|
+
},
|
|
256
|
+
async (path) => {
|
|
257
|
+
const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
|
|
258
|
+
assert.ok(lines.some((line) => line.includes("localhost:8400")));
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("--config leaves the hosted gateway alone when no private gateway is given", async () => {
|
|
264
|
+
await withConfigFile(
|
|
265
|
+
{ harnesses: ["claude-code"], claudeFunding: ["subscription"] },
|
|
266
|
+
async (path) => {
|
|
267
|
+
const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
|
|
268
|
+
assert.ok(!lines.some((line) => line.includes("localhost:8400")));
|
|
269
|
+
},
|
|
270
|
+
);
|
|
271
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"default_harness_ids": ["claude-code", "codex", "opencode"],
|
|
4
|
+
"harnesses": [
|
|
5
|
+
{
|
|
6
|
+
"id": "claude-code",
|
|
7
|
+
"name": "Claude Code",
|
|
8
|
+
"config_paths": ["~/.claude/settings.json"],
|
|
9
|
+
"penny_command": "penny claude",
|
|
10
|
+
"funding_group": "claude"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": "codex",
|
|
14
|
+
"name": "Codex",
|
|
15
|
+
"config_paths": ["~/.codex/config.toml"],
|
|
16
|
+
"penny_command": "penny codex",
|
|
17
|
+
"funding_group": "codex"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "opencode",
|
|
21
|
+
"name": "OpenCode",
|
|
22
|
+
"config_paths": [
|
|
23
|
+
"~/.config/opencode/opencode.jsonc",
|
|
24
|
+
"~/.config/opencode/opencode.json"
|
|
25
|
+
],
|
|
26
|
+
"penny_command": "penny opencode",
|
|
27
|
+
"funding_group": null
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"funding_groups": {
|
|
31
|
+
"claude": {
|
|
32
|
+
"config_key": "claudeFunding",
|
|
33
|
+
"title": "Claude Code funding",
|
|
34
|
+
"options": [
|
|
35
|
+
{
|
|
36
|
+
"id": "subscription",
|
|
37
|
+
"name": "Claude subscription",
|
|
38
|
+
"fields": []
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "anthropic-api",
|
|
42
|
+
"name": "Anthropic API key",
|
|
43
|
+
"fields": [
|
|
44
|
+
{ "config_key": "anthropicApiKey", "label": "Anthropic API key", "secret": true }
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"id": "openrouter",
|
|
49
|
+
"name": "OpenRouter API key",
|
|
50
|
+
"fields": [
|
|
51
|
+
{ "config_key": "openrouterApiKey", "label": "OpenRouter API key", "secret": true }
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"id": "anthropic-proxy",
|
|
56
|
+
"name": "Proxy (e.g., Bedrock, Azure, private server)",
|
|
57
|
+
"fields": [
|
|
58
|
+
{
|
|
59
|
+
"config_key": "proxyBaseUrl",
|
|
60
|
+
"label": "Proxy base URL",
|
|
61
|
+
"secret": false,
|
|
62
|
+
"placeholder": "https://proxy.example.com"
|
|
63
|
+
},
|
|
64
|
+
{ "config_key": "proxyApiKey", "label": "Proxy API key", "secret": true }
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
"codex": {
|
|
70
|
+
"config_key": "codexFunding",
|
|
71
|
+
"title": "Codex funding",
|
|
72
|
+
"options": [
|
|
73
|
+
{
|
|
74
|
+
"id": "subscription",
|
|
75
|
+
"name": "ChatGPT subscription",
|
|
76
|
+
"fields": []
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"id": "openai-api",
|
|
80
|
+
"name": "OpenAI API key",
|
|
81
|
+
"fields": [
|
|
82
|
+
{ "config_key": "openaiApiKey", "label": "OpenAI API key", "secret": true }
|
|
83
|
+
]
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"id": "openrouter",
|
|
87
|
+
"name": "OpenRouter API key",
|
|
88
|
+
"fields": [
|
|
89
|
+
{ "config_key": "openrouterApiKey", "label": "OpenRouter API key", "secret": true }
|
|
90
|
+
]
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"id": "openai-proxy",
|
|
94
|
+
"name": "Proxy (e.g., Bedrock, Azure, private server)",
|
|
95
|
+
"fields": [
|
|
96
|
+
{
|
|
97
|
+
"config_key": "openaiProxyBaseUrl",
|
|
98
|
+
"label": "OpenAI proxy base URL",
|
|
99
|
+
"secret": false,
|
|
100
|
+
"placeholder": "https://proxy.example.com/v1"
|
|
101
|
+
},
|
|
102
|
+
{ "config_key": "openaiProxyApiKey", "label": "OpenAI proxy API key", "secret": true }
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"advanced": {
|
|
109
|
+
"title": "Private PennyRouter gateway",
|
|
110
|
+
"description": "Route through a private PennyRouter gateway if supplied by your IT team.",
|
|
111
|
+
"fields": [
|
|
112
|
+
{
|
|
113
|
+
"config_key": "gatewayBaseUrl",
|
|
114
|
+
"label": "Gateway base URL",
|
|
115
|
+
"secret": false,
|
|
116
|
+
"placeholder": "https://gateway.example.com",
|
|
117
|
+
"requires_https": true
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"config_key": "privateKey",
|
|
121
|
+
"label": "Private gateway key",
|
|
122
|
+
"secret": true,
|
|
123
|
+
"placeholder": "pr-...",
|
|
124
|
+
"prefix": "pr-"
|
|
125
|
+
}
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
}
|
package/src/launch.js
CHANGED
|
@@ -2,18 +2,26 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
2
2
|
import {
|
|
3
3
|
chmod,
|
|
4
4
|
cp,
|
|
5
|
+
mkdtemp,
|
|
5
6
|
mkdir,
|
|
6
7
|
rm,
|
|
8
|
+
readdir,
|
|
7
9
|
stat,
|
|
10
|
+
symlink,
|
|
8
11
|
unlink,
|
|
9
12
|
writeFile,
|
|
10
13
|
} from "node:fs/promises";
|
|
11
14
|
import { constants as osConstants } from "node:os";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
12
16
|
import { fileURLToPath } from "node:url";
|
|
13
17
|
import { basename, dirname, join, resolve } from "node:path";
|
|
14
18
|
|
|
15
19
|
import { configureClaudeCodeStatusline } from "./statusline.js";
|
|
16
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
upgradeClaudeCodeModelValue,
|
|
22
|
+
writeClaudeCodeModelPresentation,
|
|
23
|
+
} from "./harnesses/claude-code.js";
|
|
24
|
+
import { renderCodexConfig } from "./harnesses/codex.js";
|
|
17
25
|
import { atomicWrite, homeDir, readJsonFile, readText } from "./state.js";
|
|
18
26
|
|
|
19
27
|
const CONFIG_VERSION = 1;
|
|
@@ -21,6 +29,10 @@ const PATH_START = "# >>> pennyrouter PATH >>>";
|
|
|
21
29
|
const PATH_END = "# <<< pennyrouter PATH <<<";
|
|
22
30
|
const CODEX_PROFILE_MARKER = "# PennyRouter per-command profile (managed; no secrets)";
|
|
23
31
|
const CLAUDE_COMMAND_MARKER = "<!-- PennyRouter managed Claude command -->";
|
|
32
|
+
// A stable UUID keeps upgrades idempotent and avoids creating a new visible profile on
|
|
33
|
+
// every launch. It is deliberately not the user's hand-authored profile id.
|
|
34
|
+
const CLAUDE_DESKTOP_PROFILE_ID = "70656e6e-7972-4f75-8465-720000000001";
|
|
35
|
+
const CLAUDE_DESKTOP_PROFILE_NAME = "PennyRouter";
|
|
24
36
|
const TERMINAL_TITLE_REFRESH_MS = 1000;
|
|
25
37
|
const TERMINAL_STATUS_REFRESH_MS = 15000;
|
|
26
38
|
|
|
@@ -59,6 +71,31 @@ export function claudeLaunchSettingsPath() {
|
|
|
59
71
|
return join(pennyConfigDir(), "claude-settings.json");
|
|
60
72
|
}
|
|
61
73
|
|
|
74
|
+
export function claudeDesktopConfigLibraryPath() {
|
|
75
|
+
if (process.platform === "win32") {
|
|
76
|
+
return join(
|
|
77
|
+
process.env.LOCALAPPDATA || process.env.APPDATA || homeDir(),
|
|
78
|
+
"Claude-3p",
|
|
79
|
+
"configLibrary",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (process.platform === "darwin") {
|
|
83
|
+
return join(homeDir(), "Library", "Application Support", "Claude-3p", "configLibrary");
|
|
84
|
+
}
|
|
85
|
+
return join(
|
|
86
|
+
process.env.XDG_CONFIG_HOME || join(homeDir(), ".config"),
|
|
87
|
+
"Claude-3p",
|
|
88
|
+
"configLibrary",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function claudeDesktopCredentialHelperPath() {
|
|
93
|
+
return join(
|
|
94
|
+
pennyConfigDir(),
|
|
95
|
+
process.platform === "win32" ? "claude-desktop-credential.cmd" : "claude-desktop-credential",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
62
99
|
export function codexLaunchProfilePath() {
|
|
63
100
|
const codexHome = process.env.CODEX_HOME || join(homeDir(), ".codex");
|
|
64
101
|
return join(codexHome, "pennyrouter.config.toml");
|
|
@@ -78,6 +115,11 @@ async function privateWrite(path, text) {
|
|
|
78
115
|
await chmod(path, 0o600);
|
|
79
116
|
}
|
|
80
117
|
|
|
118
|
+
async function executableWrite(path, text) {
|
|
119
|
+
await privateWrite(path, text);
|
|
120
|
+
if (process.platform !== "win32") await chmod(path, 0o700);
|
|
121
|
+
}
|
|
122
|
+
|
|
81
123
|
export async function loadPennyConfig() {
|
|
82
124
|
return (await readJsonFile(pennyConfigPath(), null)) || {
|
|
83
125
|
version: CONFIG_VERSION,
|
|
@@ -274,6 +316,258 @@ export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
|
|
|
274
316
|
].join("\n");
|
|
275
317
|
}
|
|
276
318
|
|
|
319
|
+
export function codexDesktopExecutable() {
|
|
320
|
+
if (process.platform === "darwin") return "/Applications/Codex.app/Contents/MacOS/Codex";
|
|
321
|
+
if (process.platform === "win32") return "Codex.exe";
|
|
322
|
+
return "codex";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function claudeDesktopExecutable() {
|
|
326
|
+
if (process.platform === "darwin") return "/Applications/Claude.app/Contents/MacOS/Claude";
|
|
327
|
+
if (process.platform === "win32") return "Claude.exe";
|
|
328
|
+
return "claude-desktop";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function shellQuote(value) {
|
|
332
|
+
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function renderClaudeDesktopCredentialHelper() {
|
|
336
|
+
const entry = join(pennyRuntimeDir(), "bin", "penny.js");
|
|
337
|
+
if (process.platform === "win32") {
|
|
338
|
+
return `@echo off\r\n"${process.execPath.replaceAll('"', '""')}" "${entry.replaceAll('"', '""')}" __claude-desktop-credential-helper\r\n`;
|
|
339
|
+
}
|
|
340
|
+
return `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(entry)} __claude-desktop-credential-helper\n`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Apply a dedicated local Claude Desktop 3P profile without touching Claude Code's
|
|
345
|
+
* ~/.claude settings. The profile contains no credential: Claude invokes the helper,
|
|
346
|
+
* which reads the existing mode-0600 Penny config at request time.
|
|
347
|
+
*/
|
|
348
|
+
export async function configureClaudeDesktopProfile(config) {
|
|
349
|
+
if (!config.api_key) {
|
|
350
|
+
throw new Error("No PennyRouter key found. Run `npx pennyrouter install` first.");
|
|
351
|
+
}
|
|
352
|
+
if (!config.gateway_base_url) {
|
|
353
|
+
throw new Error("No PennyRouter gateway configured. Run `npx pennyrouter install` first.");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const library = claudeDesktopConfigLibraryPath();
|
|
357
|
+
const metaPath = join(library, "_meta.json");
|
|
358
|
+
const profilePath = join(library, `${CLAUDE_DESKTOP_PROFILE_ID}.json`);
|
|
359
|
+
const helperPath = claudeDesktopCredentialHelperPath();
|
|
360
|
+
const meta = (await readJsonFile(metaPath, null)) || { entries: [] };
|
|
361
|
+
const entries = Array.isArray(meta.entries) ? meta.entries : [];
|
|
362
|
+
const priorState = config.claude_desktop || {};
|
|
363
|
+
const previousAppliedId = meta.appliedId !== CLAUDE_DESKTOP_PROFILE_ID
|
|
364
|
+
? (meta.appliedId || priorState.previous_applied_id || null)
|
|
365
|
+
: (priorState.previous_applied_id || null);
|
|
366
|
+
|
|
367
|
+
await executableWrite(helperPath, renderClaudeDesktopCredentialHelper());
|
|
368
|
+
const gatewayBaseUrl = String(config.gateway_base_url)
|
|
369
|
+
.replace(/\/+$/, "")
|
|
370
|
+
.replace(/\/v1$/, "");
|
|
371
|
+
const profile = {
|
|
372
|
+
inferenceProvider: "gateway",
|
|
373
|
+
inferenceGatewayBaseUrl: gatewayBaseUrl,
|
|
374
|
+
inferenceGatewayAuthScheme: "x-api-key",
|
|
375
|
+
inferenceCredentialKind: "helper-script",
|
|
376
|
+
inferenceCredentialHelper: helperPath,
|
|
377
|
+
inferenceCredentialHelperTtlSec: 60,
|
|
378
|
+
// Let Desktop populate its picker from PennyRouter's Anthropic-compatible
|
|
379
|
+
// /v1/models response. Do not also set inferenceModels: that key is an explicit
|
|
380
|
+
// override and would silently suppress discovery.
|
|
381
|
+
modelDiscoveryEnabled: true,
|
|
382
|
+
};
|
|
383
|
+
await privateWrite(profilePath, `${JSON.stringify(profile, null, 2)}\n`);
|
|
384
|
+
const nextEntries = entries.filter((entry) => entry?.id !== CLAUDE_DESKTOP_PROFILE_ID);
|
|
385
|
+
nextEntries.push({ id: CLAUDE_DESKTOP_PROFILE_ID, name: CLAUDE_DESKTOP_PROFILE_NAME });
|
|
386
|
+
await privateWrite(metaPath, `${JSON.stringify({
|
|
387
|
+
...meta,
|
|
388
|
+
appliedId: CLAUDE_DESKTOP_PROFILE_ID,
|
|
389
|
+
entries: nextEntries,
|
|
390
|
+
}, null, 2)}\n`);
|
|
391
|
+
|
|
392
|
+
config.claude_desktop = {
|
|
393
|
+
managed_profile_id: CLAUDE_DESKTOP_PROFILE_ID,
|
|
394
|
+
previous_applied_id: previousAppliedId,
|
|
395
|
+
};
|
|
396
|
+
await savePennyConfig(config);
|
|
397
|
+
return { helperPath, library, profilePath, previousAppliedId };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Select standard, first-party Claude Desktop without deleting PennyRouter's saved
|
|
402
|
+
* third-party profile. Claude chooses its data directory and provider once at startup;
|
|
403
|
+
* an absent local appliedId is the standard/native selection, while the Penny profile
|
|
404
|
+
* remains available for the next explicit gateway launch.
|
|
405
|
+
*/
|
|
406
|
+
export async function selectClaudeDesktopNativeMode() {
|
|
407
|
+
const library = claudeDesktopConfigLibraryPath();
|
|
408
|
+
const metaPath = join(library, "_meta.json");
|
|
409
|
+
const meta = await readJsonFile(metaPath, null);
|
|
410
|
+
if (!meta || !Object.hasOwn(meta, "appliedId")) return;
|
|
411
|
+
const next = { ...meta };
|
|
412
|
+
delete next.appliedId;
|
|
413
|
+
await privateWrite(metaPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Read the local Claude Desktop mode selected for the next app launch. */
|
|
417
|
+
export async function readClaudeDesktopMode() {
|
|
418
|
+
const library = claudeDesktopConfigLibraryPath();
|
|
419
|
+
const meta = await readJsonFile(join(library, "_meta.json"), null);
|
|
420
|
+
const appliedId = meta?.appliedId || null;
|
|
421
|
+
const entries = Array.isArray(meta?.entries) ? meta.entries : [];
|
|
422
|
+
const entry = entries.find((candidate) => candidate?.id === appliedId);
|
|
423
|
+
if (!appliedId) {
|
|
424
|
+
return { mode: "native", label: "Native Claude", profileId: null, profileName: null };
|
|
425
|
+
}
|
|
426
|
+
if (appliedId === CLAUDE_DESKTOP_PROFILE_ID) {
|
|
427
|
+
return {
|
|
428
|
+
mode: "gateway",
|
|
429
|
+
label: "PennyRouter Gateway",
|
|
430
|
+
profileId: appliedId,
|
|
431
|
+
profileName: CLAUDE_DESKTOP_PROFILE_NAME,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
mode: "third-party",
|
|
436
|
+
label: entry?.name ? `Third-party: ${entry.name}` : "Third-party inference",
|
|
437
|
+
profileId: appliedId,
|
|
438
|
+
profileName: entry?.name || null,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Claude is a single-instance app and reads provider configuration only at launch.
|
|
444
|
+
* Refuse mode changes while its main process is alive so an on-disk selection never
|
|
445
|
+
* disagrees with the mode the visible app is actually using.
|
|
446
|
+
*/
|
|
447
|
+
export function isClaudeDesktopRunning(run = spawnSync) {
|
|
448
|
+
return isDesktopProcessRunning(
|
|
449
|
+
process.platform === "darwin" ? ["Claude"] : ["claude-desktop", "Claude"],
|
|
450
|
+
"Claude.exe",
|
|
451
|
+
run,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function isCodexDesktopRunning(run = spawnSync) {
|
|
456
|
+
return isDesktopProcessRunning(
|
|
457
|
+
process.platform === "darwin" ? ["Codex"] : ["codex-desktop", "Codex"],
|
|
458
|
+
"Codex.exe",
|
|
459
|
+
run,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function isDesktopProcessRunning(names, windowsImageName, run) {
|
|
464
|
+
if (process.platform === "win32") {
|
|
465
|
+
const result = run("tasklist", ["/FI", `IMAGENAME eq ${windowsImageName}`, "/NH"], {
|
|
466
|
+
encoding: "utf8",
|
|
467
|
+
windowsHide: true,
|
|
468
|
+
});
|
|
469
|
+
return result.status === 0 && (result.stdout || "").toLowerCase()
|
|
470
|
+
.includes(windowsImageName.toLowerCase());
|
|
471
|
+
}
|
|
472
|
+
return names.some((name) => run("pgrep", ["-x", name], {
|
|
473
|
+
encoding: "utf8",
|
|
474
|
+
stdio: "ignore",
|
|
475
|
+
}).status === 0);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export async function restoreClaudeDesktopProfile() {
|
|
479
|
+
const config = await loadPennyConfig();
|
|
480
|
+
const state = config.claude_desktop;
|
|
481
|
+
const library = claudeDesktopConfigLibraryPath();
|
|
482
|
+
const metaPath = join(library, "_meta.json");
|
|
483
|
+
const profilePath = join(library, `${CLAUDE_DESKTOP_PROFILE_ID}.json`);
|
|
484
|
+
const meta = await readJsonFile(metaPath, null);
|
|
485
|
+
|
|
486
|
+
if (meta) {
|
|
487
|
+
const entries = (Array.isArray(meta.entries) ? meta.entries : [])
|
|
488
|
+
.filter((entry) => entry?.id !== CLAUDE_DESKTOP_PROFILE_ID);
|
|
489
|
+
const next = { ...meta, entries };
|
|
490
|
+
if (meta.appliedId === CLAUDE_DESKTOP_PROFILE_ID) {
|
|
491
|
+
const previous = state?.previous_applied_id;
|
|
492
|
+
const previousExists = previous
|
|
493
|
+
&& entries.some((entry) => entry?.id === previous)
|
|
494
|
+
&& await readText(join(library, `${previous}.json`), "");
|
|
495
|
+
if (previousExists) next.appliedId = previous;
|
|
496
|
+
else delete next.appliedId;
|
|
497
|
+
}
|
|
498
|
+
await privateWrite(metaPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
499
|
+
}
|
|
500
|
+
await unlink(profilePath).catch((error) => {
|
|
501
|
+
if (error.code !== "ENOENT") throw error;
|
|
502
|
+
});
|
|
503
|
+
await unlink(claudeDesktopCredentialHelperPath()).catch((error) => {
|
|
504
|
+
if (error.code !== "ENOENT") throw error;
|
|
505
|
+
});
|
|
506
|
+
if (state) {
|
|
507
|
+
delete config.claude_desktop;
|
|
508
|
+
await savePennyConfig(config);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export async function readClaudeDesktopCredential() {
|
|
513
|
+
const config = await loadPennyConfig();
|
|
514
|
+
if (!config.api_key) throw new Error("PennyRouter credential is not configured.");
|
|
515
|
+
return config.api_key;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export async function runClaudeDesktopCredentialHelper() {
|
|
519
|
+
process.stdout.write(await readClaudeDesktopCredential());
|
|
520
|
+
return 0;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Build a disposable Codex home for the desktop app. Codex Desktop does not accept the
|
|
525
|
+
* CLI's --profile flag, so its default provider has to be selected in config.toml. Putting
|
|
526
|
+
* that config in an overlay keeps the user's real Codex mode native after the app exits,
|
|
527
|
+
* including after a crash or force-quit.
|
|
528
|
+
*/
|
|
529
|
+
export async function createCodexDesktopHome({ config, realHome }) {
|
|
530
|
+
const overlay = await mkdtemp(join(tmpdir(), "penny-codex-desktop-"));
|
|
531
|
+
try {
|
|
532
|
+
for (const entry of await readdir(realHome)) {
|
|
533
|
+
if (entry === "config.toml") continue;
|
|
534
|
+
await symlink(join(realHome, entry), join(overlay, entry));
|
|
535
|
+
}
|
|
536
|
+
if (!config.api_key) throw new Error("No PennyRouter key found. Run `npx pennyrouter install` first.");
|
|
537
|
+
if (!config.gateway_base_url) throw new Error("No PennyRouter gateway configured. Run `npx pennyrouter install` first.");
|
|
538
|
+
const nativeConfig = await readText(join(realHome, "config.toml"), "");
|
|
539
|
+
const desktopConfig = setCodexDesktopDefault(renderCodexConfig(removeCodexPennyProvider(nativeConfig), {
|
|
540
|
+
apiKey: config.api_key,
|
|
541
|
+
gatewayBaseUrl: config.gateway_base_url,
|
|
542
|
+
subscriptionAuth: Boolean(config.harnesses?.codex?.subscription_auth),
|
|
543
|
+
}));
|
|
544
|
+
await privateWrite(join(overlay, "config.toml"), desktopConfig);
|
|
545
|
+
return overlay;
|
|
546
|
+
} catch (error) {
|
|
547
|
+
await rm(overlay, { recursive: true, force: true });
|
|
548
|
+
throw error;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function setCodexDesktopDefault(text) {
|
|
553
|
+
const lines = String(text).split("\n");
|
|
554
|
+
const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
|
|
555
|
+
const rootEnd = firstTable < 0 ? lines.length : firstTable;
|
|
556
|
+
const root = lines.slice(0, rootEnd).filter((line) => !/^\s*model_provider\s*=/.test(line));
|
|
557
|
+
root.unshift('model_provider = "pennyrouter"');
|
|
558
|
+
return [...root, ...lines.slice(rootEnd)].join("\n");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function removeCodexPennyProvider(text) {
|
|
562
|
+
const lines = String(text).split("\n");
|
|
563
|
+
const start = lines.findIndex((line) => /^\s*\[model_providers\.pennyrouter\]\s*$/.test(line));
|
|
564
|
+
if (start < 0) return String(text);
|
|
565
|
+
let end = start + 1;
|
|
566
|
+
while (end < lines.length && !/^\s*\[/.test(lines[end])) end += 1;
|
|
567
|
+
lines.splice(start, end - start);
|
|
568
|
+
return lines.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
569
|
+
}
|
|
570
|
+
|
|
277
571
|
function terminalTitle(text) {
|
|
278
572
|
// OSC titles are interpreted by the terminal, so remove control characters and
|
|
279
573
|
// the OSC terminators rather than allowing gateway-provided text into the escape.
|
|
@@ -290,14 +584,15 @@ function renderPennyTerminalTitle(status) {
|
|
|
290
584
|
if (status?.model_display_name) parts.push(status.model_display_name);
|
|
291
585
|
else if (status?.model) parts.push(status.model);
|
|
292
586
|
if (status?.auth_mode) parts.push(`(${status.auth_mode})`);
|
|
293
|
-
|
|
587
|
+
const warmth = renderCacheWarmth(status);
|
|
588
|
+
if (warmth) parts.push(warmth);
|
|
294
589
|
return parts.join(" · ");
|
|
295
590
|
}
|
|
296
591
|
|
|
297
592
|
function renderCacheWarmth(status) {
|
|
298
593
|
const expiresAt = status?.cache_expires_at;
|
|
299
594
|
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0) {
|
|
300
|
-
return "
|
|
595
|
+
return "";
|
|
301
596
|
}
|
|
302
597
|
const remaining = Math.floor(expiresAt - Date.now() / 1000);
|
|
303
598
|
if (remaining <= 0) return "❄️ cold";
|
|
@@ -498,6 +793,9 @@ function removeWindowsPathEntry() {
|
|
|
498
793
|
}
|
|
499
794
|
|
|
500
795
|
export async function removePennyRuntime() {
|
|
796
|
+
// The Desktop profile's credential helper lives in the Penny runtime. Restore the
|
|
797
|
+
// previously selected Claude Desktop profile before removing that executable.
|
|
798
|
+
await restoreClaudeDesktopProfile();
|
|
501
799
|
const config = await loadPennyConfig();
|
|
502
800
|
if (process.platform === "win32") {
|
|
503
801
|
if (config.windows_path_managed) removeWindowsPathEntry();
|
|
@@ -597,6 +895,56 @@ export async function runPenny(argv = process.argv.slice(2)) {
|
|
|
597
895
|
throw new Error(`Unknown harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
|
|
598
896
|
}
|
|
599
897
|
const config = await loadPennyConfig();
|
|
898
|
+
if (harness === "claude" && argv[1]?.toLowerCase() === "desktop") {
|
|
899
|
+
const option = argv[2]?.toLowerCase();
|
|
900
|
+
if (option === "--restore" || option === "restore") {
|
|
901
|
+
if (argv.length > 3) {
|
|
902
|
+
throw new Error("Usage: penny claude desktop [gateway|native|status|--restore]");
|
|
903
|
+
}
|
|
904
|
+
await restoreClaudeDesktopProfile();
|
|
905
|
+
console.log("PennyRouter's Claude Desktop profile was removed and the previous profile restored.");
|
|
906
|
+
console.log("Fully quit and reopen Claude Desktop for the restored profile to take effect.");
|
|
907
|
+
return 0;
|
|
908
|
+
}
|
|
909
|
+
if (option === "status" || option === "--status") {
|
|
910
|
+
const json = argv[3]?.toLowerCase() === "--json";
|
|
911
|
+
if (argv.length > (json ? 4 : 3)) {
|
|
912
|
+
throw new Error("Usage: penny claude desktop status [--json]");
|
|
913
|
+
}
|
|
914
|
+
const status = { ...(await readClaudeDesktopMode()), running: isClaudeDesktopRunning() };
|
|
915
|
+
if (json) console.log(JSON.stringify(status));
|
|
916
|
+
else {
|
|
917
|
+
console.log(`Claude Desktop mode: ${status.label}`);
|
|
918
|
+
console.log(`Claude Desktop is ${status.running ? "running" : "not running"}.`);
|
|
919
|
+
}
|
|
920
|
+
return 0;
|
|
921
|
+
}
|
|
922
|
+
const mode = option === "native" || option === "--native" ? "native" : "gateway";
|
|
923
|
+
const legacyLogs = option === "--logs" || option === "logs";
|
|
924
|
+
const modeLogs = argv[3]?.toLowerCase() === "--logs" || argv[3]?.toLowerCase() === "logs";
|
|
925
|
+
const logs = legacyLogs || modeLogs;
|
|
926
|
+
const validOption = option === undefined || legacyLogs
|
|
927
|
+
|| ["gateway", "--gateway", "native", "--native"].includes(option);
|
|
928
|
+
if (!validOption || argv.length > (modeLogs ? 4 : (legacyLogs ? 3 : (option ? 3 : 2)))) {
|
|
929
|
+
throw new Error(
|
|
930
|
+
"Usage: penny claude desktop [gateway|native|status] [--logs|--json]",
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
return runClaudeDesktop(config, { logs, mode });
|
|
934
|
+
}
|
|
935
|
+
if (harness === "codex" && argv[1]?.toLowerCase() === "desktop") {
|
|
936
|
+
const option = argv[2]?.toLowerCase();
|
|
937
|
+
const mode = option === "native" || option === "--native" ? "native" : "gateway";
|
|
938
|
+
const legacyLogs = option === "--logs" || option === "logs";
|
|
939
|
+
const modeLogs = argv[3]?.toLowerCase() === "--logs" || argv[3]?.toLowerCase() === "logs";
|
|
940
|
+
const logs = legacyLogs || modeLogs;
|
|
941
|
+
const validOption = option === undefined || legacyLogs
|
|
942
|
+
|| ["gateway", "--gateway", "native", "--native"].includes(option);
|
|
943
|
+
if (!validOption || argv.length > (modeLogs ? 4 : (legacyLogs ? 3 : (option ? 3 : 2)))) {
|
|
944
|
+
throw new Error("Usage: penny codex desktop [gateway|native] [--logs]");
|
|
945
|
+
}
|
|
946
|
+
return runCodexDesktop(config, { logs, mode });
|
|
947
|
+
}
|
|
600
948
|
const invocation = buildPennyInvocation(harness, argv.slice(1), config);
|
|
601
949
|
const child = spawn(invocation.command, invocation.args, {
|
|
602
950
|
cwd: process.cwd(),
|
|
@@ -604,7 +952,7 @@ export async function runPenny(argv = process.argv.slice(2)) {
|
|
|
604
952
|
stdio: "inherit",
|
|
605
953
|
shell: process.platform === "win32",
|
|
606
954
|
});
|
|
607
|
-
const stopTerminalTitle = ["codex", "opencode"].includes(harness)
|
|
955
|
+
const stopTerminalTitle = ["claude", "codex", "opencode"].includes(harness)
|
|
608
956
|
? startPennyTerminalTitle({
|
|
609
957
|
gatewayBaseUrl: config.gateway_base_url,
|
|
610
958
|
apiKey: config.api_key,
|
|
@@ -616,6 +964,92 @@ export async function runPenny(argv = process.argv.slice(2)) {
|
|
|
616
964
|
return result.code ?? 1;
|
|
617
965
|
}
|
|
618
966
|
|
|
967
|
+
async function runClaudeDesktop(config, { logs = false, mode = "gateway" } = {}) {
|
|
968
|
+
if (isClaudeDesktopRunning()) {
|
|
969
|
+
throw new Error(
|
|
970
|
+
`Claude Desktop is already running. Fully quit it before switching to ${mode} mode.`,
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
if (mode === "native") await selectClaudeDesktopNativeMode();
|
|
974
|
+
else await configureClaudeDesktopProfile(config);
|
|
975
|
+
const child = spawn(claudeDesktopExecutable(), [], {
|
|
976
|
+
cwd: process.cwd(),
|
|
977
|
+
env: process.env,
|
|
978
|
+
stdio: logs ? "inherit" : "ignore",
|
|
979
|
+
detached: !logs,
|
|
980
|
+
shell: process.platform === "win32",
|
|
981
|
+
});
|
|
982
|
+
if (!logs) {
|
|
983
|
+
child.unref();
|
|
984
|
+
console.log(mode === "native"
|
|
985
|
+
? "Claude Desktop launched in native mode (detached)."
|
|
986
|
+
: "Claude Desktop launched with PennyRouter Gateway (detached).");
|
|
987
|
+
console.log("Use `penny claude desktop status` to see the selected mode.");
|
|
988
|
+
return 0;
|
|
989
|
+
}
|
|
990
|
+
const result = await waitForChild(child);
|
|
991
|
+
if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
|
|
992
|
+
return result.code ?? 1;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
async function runCodexDesktop(config, { logs = false, mode = "gateway" } = {}) {
|
|
996
|
+
if (isCodexDesktopRunning()) {
|
|
997
|
+
throw new Error(`Codex Desktop is already running. Fully quit it before launching ${mode} mode.`);
|
|
998
|
+
}
|
|
999
|
+
if (mode === "native") {
|
|
1000
|
+
const child = spawn(codexDesktopExecutable(), [], {
|
|
1001
|
+
cwd: process.cwd(),
|
|
1002
|
+
env: process.env,
|
|
1003
|
+
stdio: logs ? "inherit" : "ignore",
|
|
1004
|
+
detached: !logs,
|
|
1005
|
+
shell: process.platform === "win32",
|
|
1006
|
+
});
|
|
1007
|
+
if (!logs) {
|
|
1008
|
+
child.unref();
|
|
1009
|
+
console.log("Codex Desktop launched in native mode (detached).");
|
|
1010
|
+
return 0;
|
|
1011
|
+
}
|
|
1012
|
+
const result = await waitForChild(child);
|
|
1013
|
+
if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
|
|
1014
|
+
return result.code ?? 1;
|
|
1015
|
+
}
|
|
1016
|
+
const realHome = process.env.CODEX_HOME || join(homeDir(), ".codex");
|
|
1017
|
+
const overlay = await createCodexDesktopHome({ config, realHome });
|
|
1018
|
+
if (!logs) {
|
|
1019
|
+
const supervisor = spawn(process.execPath, [
|
|
1020
|
+
fileURLToPath(new URL("../bin/penny.js", import.meta.url)),
|
|
1021
|
+
"__codex-desktop-supervisor",
|
|
1022
|
+
overlay,
|
|
1023
|
+
], {
|
|
1024
|
+
cwd: process.cwd(),
|
|
1025
|
+
env: process.env,
|
|
1026
|
+
stdio: "ignore",
|
|
1027
|
+
detached: true,
|
|
1028
|
+
});
|
|
1029
|
+
supervisor.unref();
|
|
1030
|
+
console.log("Codex Desktop launched with PennyRouter (detached). Use `penny codex desktop gateway --logs` to keep logs in this terminal.");
|
|
1031
|
+
return 0;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
return runCodexDesktopSupervisor(overlay);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
export async function runCodexDesktopSupervisor(overlay) {
|
|
1038
|
+
const child = spawn(codexDesktopExecutable(), [], {
|
|
1039
|
+
cwd: process.cwd(),
|
|
1040
|
+
env: { ...process.env, CODEX_HOME: overlay },
|
|
1041
|
+
stdio: "inherit",
|
|
1042
|
+
shell: process.platform === "win32",
|
|
1043
|
+
});
|
|
1044
|
+
try {
|
|
1045
|
+
const result = await waitForChild(child);
|
|
1046
|
+
if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
|
|
1047
|
+
return result.code ?? 1;
|
|
1048
|
+
} finally {
|
|
1049
|
+
await rm(overlay, { recursive: true, force: true });
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
619
1053
|
export async function fileMode(path) {
|
|
620
1054
|
return (await stat(path)).mode & 0o777;
|
|
621
1055
|
}
|
package/src/launch.test.js
CHANGED
|
@@ -1,22 +1,33 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
|
|
6
7
|
import {
|
|
7
8
|
buildPennyInvocation,
|
|
9
|
+
claudeDesktopConfigLibraryPath,
|
|
10
|
+
claudeDesktopCredentialHelperPath,
|
|
8
11
|
claudeLaunchSettingsPath,
|
|
9
12
|
claudeUserSkillPath,
|
|
10
13
|
codexLaunchProfilePath,
|
|
14
|
+
configureClaudeDesktopProfile,
|
|
15
|
+
createCodexDesktopHome,
|
|
11
16
|
configurePennyHarness,
|
|
12
17
|
fileMode,
|
|
13
18
|
installPennyRuntime,
|
|
19
|
+
isClaudeDesktopRunning,
|
|
20
|
+
isCodexDesktopRunning,
|
|
14
21
|
loadPennyConfig,
|
|
15
22
|
pennyConfigPath,
|
|
16
23
|
pennyRuntimeDir,
|
|
17
24
|
pennyShimPath,
|
|
25
|
+
readClaudeDesktopCredential,
|
|
26
|
+
readClaudeDesktopMode,
|
|
18
27
|
removePennyHarness,
|
|
19
28
|
removePennyRuntime,
|
|
29
|
+
restoreClaudeDesktopProfile,
|
|
30
|
+
selectClaudeDesktopNativeMode,
|
|
20
31
|
renderCodexLaunchProfile,
|
|
21
32
|
} from "./launch.js";
|
|
22
33
|
import { claudeCodeHarness } from "./harnesses/claude-code.js";
|
|
@@ -40,7 +51,7 @@ async function main() {
|
|
|
40
51
|
permissions: { allow: ["Read"] },
|
|
41
52
|
statusLine: { type: "command", command: "native-status" },
|
|
42
53
|
}, null, 2)}\n`;
|
|
43
|
-
const nativeCodex = 'model = "native-model"\n';
|
|
54
|
+
const nativeCodex = 'model = "native-model"\n\nmodel_provider = "pennyrouter"\n\n[model_providers.pennyrouter]\nname = "PennyRouter"\nbase_url = "http://localhost:8400/v1"\n';
|
|
44
55
|
fs.writeFileSync(path.join(home, ".claude", "settings.json"), nativeClaude);
|
|
45
56
|
fs.writeFileSync(path.join(home, ".codex", "config.toml"), nativeCodex);
|
|
46
57
|
fs.writeFileSync(path.join(home, ".zshrc"), "# user setting\n");
|
|
@@ -56,6 +67,21 @@ async function main() {
|
|
|
56
67
|
});
|
|
57
68
|
|
|
58
69
|
try {
|
|
70
|
+
const desktopLibrary = claudeDesktopConfigLibraryPath();
|
|
71
|
+
const nativeDesktopId = "native-profile";
|
|
72
|
+
fs.mkdirSync(desktopLibrary, { recursive: true });
|
|
73
|
+
fs.writeFileSync(
|
|
74
|
+
path.join(desktopLibrary, `${nativeDesktopId}.json`),
|
|
75
|
+
`${JSON.stringify({ inferenceProvider: "anthropic" }, null, 2)}\n`,
|
|
76
|
+
);
|
|
77
|
+
fs.writeFileSync(
|
|
78
|
+
path.join(desktopLibrary, "_meta.json"),
|
|
79
|
+
`${JSON.stringify({
|
|
80
|
+
appliedId: nativeDesktopId,
|
|
81
|
+
entries: [{ id: nativeDesktopId, name: "Native" }],
|
|
82
|
+
}, null, 2)}\n`,
|
|
83
|
+
);
|
|
84
|
+
|
|
59
85
|
const first = await installPennyRuntime();
|
|
60
86
|
const staleSkill = path.join(pennyRuntimeDir(), "claude-plugin", "skills", "penny", "SKILL.md");
|
|
61
87
|
fs.mkdirSync(path.dirname(staleSkill), { recursive: true });
|
|
@@ -97,6 +123,65 @@ async function main() {
|
|
|
97
123
|
const config = await loadPennyConfig();
|
|
98
124
|
assert.equal(config.api_key, "pr-super-secret");
|
|
99
125
|
assert.equal(config.harnesses.opencode.enabled, true);
|
|
126
|
+
const desktop = await configureClaudeDesktopProfile(config);
|
|
127
|
+
const desktopProfile = JSON.parse(fs.readFileSync(desktop.profilePath, "utf8"));
|
|
128
|
+
const desktopMeta = JSON.parse(
|
|
129
|
+
fs.readFileSync(path.join(desktopLibrary, "_meta.json"), "utf8"),
|
|
130
|
+
);
|
|
131
|
+
assert.equal(desktopProfile.inferenceProvider, "gateway");
|
|
132
|
+
assert.equal(desktopProfile.inferenceGatewayBaseUrl, "https://gateway.example");
|
|
133
|
+
assert.equal(desktopProfile.inferenceCredentialKind, "helper-script");
|
|
134
|
+
assert.equal(desktopProfile.inferenceGatewayApiKey, undefined);
|
|
135
|
+
assert.equal(desktopProfile.modelDiscoveryEnabled, true);
|
|
136
|
+
assert.equal(desktopProfile.inferenceModels, undefined);
|
|
137
|
+
assert.equal(desktopMeta.appliedId, "70656e6e-7972-4f75-8465-720000000001");
|
|
138
|
+
assert.equal((await loadPennyConfig()).claude_desktop.previous_applied_id, nativeDesktopId);
|
|
139
|
+
assert.equal(await readClaudeDesktopCredential(), "pr-super-secret");
|
|
140
|
+
assert.equal(await fileMode(claudeDesktopCredentialHelperPath()), 0o700);
|
|
141
|
+
const helperResult = spawnSync(claudeDesktopCredentialHelperPath(), [], {
|
|
142
|
+
encoding: "utf8",
|
|
143
|
+
env: process.env,
|
|
144
|
+
});
|
|
145
|
+
assert.equal(helperResult.status, 0, helperResult.stderr);
|
|
146
|
+
assert.equal(helperResult.stdout, "pr-super-secret");
|
|
147
|
+
assert.equal((await readClaudeDesktopMode()).mode, "gateway");
|
|
148
|
+
|
|
149
|
+
await selectClaudeDesktopNativeMode();
|
|
150
|
+
const nativeModeMeta = JSON.parse(
|
|
151
|
+
fs.readFileSync(path.join(desktopLibrary, "_meta.json"), "utf8"),
|
|
152
|
+
);
|
|
153
|
+
assert.equal(nativeModeMeta.appliedId, undefined);
|
|
154
|
+
assert.ok(fs.existsSync(desktop.profilePath), "native mode preserves the Penny profile");
|
|
155
|
+
assert.ok(fs.existsSync(claudeDesktopCredentialHelperPath()), "native mode preserves its helper");
|
|
156
|
+
assert.equal((await readClaudeDesktopMode()).mode, "native");
|
|
157
|
+
|
|
158
|
+
await configureClaudeDesktopProfile(await loadPennyConfig());
|
|
159
|
+
assert.equal((await readClaudeDesktopMode()).mode, "gateway");
|
|
160
|
+
assert.equal((await loadPennyConfig()).claude_desktop.previous_applied_id, nativeDesktopId);
|
|
161
|
+
|
|
162
|
+
assert.equal(isClaudeDesktopRunning(() => ({ status: 1, stdout: "" })), false);
|
|
163
|
+
assert.equal(isClaudeDesktopRunning(() => ({ status: 0, stdout: "Claude" })), true);
|
|
164
|
+
assert.equal(isCodexDesktopRunning(() => ({ status: 1, stdout: "" })), false);
|
|
165
|
+
assert.equal(isCodexDesktopRunning(() => ({ status: 0, stdout: "Codex" })), true);
|
|
166
|
+
|
|
167
|
+
await restoreClaudeDesktopProfile();
|
|
168
|
+
const restoredDesktopMeta = JSON.parse(
|
|
169
|
+
fs.readFileSync(path.join(desktopLibrary, "_meta.json"), "utf8"),
|
|
170
|
+
);
|
|
171
|
+
assert.equal(restoredDesktopMeta.appliedId, nativeDesktopId);
|
|
172
|
+
assert.ok(!fs.existsSync(desktop.profilePath));
|
|
173
|
+
assert.ok(!fs.existsSync(claudeDesktopCredentialHelperPath()));
|
|
174
|
+
await configureClaudeDesktopProfile(await loadPennyConfig());
|
|
175
|
+
const desktopHome = await createCodexDesktopHome({
|
|
176
|
+
config,
|
|
177
|
+
realHome: path.join(home, ".codex"),
|
|
178
|
+
});
|
|
179
|
+
const desktopConfig = fs.readFileSync(path.join(desktopHome, "config.toml"), "utf8");
|
|
180
|
+
assert.match(desktopConfig, /^model_provider = "pennyrouter"/);
|
|
181
|
+
assert.match(desktopConfig, /\[model_providers\.pennyrouter\]/);
|
|
182
|
+
assert.match(desktopConfig, /http_headers = \{ "x-pennyrouter-key" = "pr-super-secret" \}/);
|
|
183
|
+
assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
|
|
184
|
+
fs.rmSync(desktopHome, { recursive: true, force: true });
|
|
100
185
|
const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
|
|
101
186
|
assert.ok(codexProfile.includes('env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }'));
|
|
102
187
|
assert.ok(codexProfile.includes("[tui]\nterminal_title = []"));
|
|
@@ -174,6 +259,13 @@ async function main() {
|
|
|
174
259
|
assert.ok(!fs.existsSync(pennyShimPath()));
|
|
175
260
|
assert.ok(!fs.existsSync(pennyRuntimeDir()));
|
|
176
261
|
assert.ok(!fs.existsSync(pennyConfigPath()));
|
|
262
|
+
const uninstallDesktopMeta = JSON.parse(
|
|
263
|
+
fs.readFileSync(path.join(desktopLibrary, "_meta.json"), "utf8"),
|
|
264
|
+
);
|
|
265
|
+
assert.equal(uninstallDesktopMeta.appliedId, nativeDesktopId);
|
|
266
|
+
assert.ok(!uninstallDesktopMeta.entries.some(
|
|
267
|
+
(entry) => entry.id === "70656e6e-7972-4f75-8465-720000000001",
|
|
268
|
+
));
|
|
177
269
|
assert.equal(fs.readFileSync(path.join(home, ".zshrc"), "utf8"), "# user setting\n");
|
|
178
270
|
assert.equal(fs.readFileSync(path.join(home, ".zprofile"), "utf8"), "");
|
|
179
271
|
|
package/src/statusline.js
CHANGED
|
@@ -135,9 +135,6 @@ if (process.argv[2] === "--refresh") {
|
|
|
135
135
|
});
|
|
136
136
|
if (resp.ok) {
|
|
137
137
|
const data = await resp.json();
|
|
138
|
-
if (data.cache_expires_at && typeof data.cache_expires_at === "number") {
|
|
139
|
-
data.cache_expires_at = Math.round(data.cache_expires_at / 10) * 10;
|
|
140
|
-
}
|
|
141
138
|
mkdirSync(cacheDir, { recursive: true });
|
|
142
139
|
writeFileSync(join(cacheDir, sessionId + ".json"), JSON.stringify(data));
|
|
143
140
|
}
|