routstrd 0.3.11 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/COCO-2.0.0-MIGRATION-PLAN.md +1191 -0
  2. package/IMPLEMENTATION.md +253 -0
  3. package/README.md +46 -0
  4. package/SECURITY.md +23 -0
  5. package/SKILL.md +13 -3
  6. package/bun.lock +68 -41
  7. package/dist/daemon/index.js +48837 -11383
  8. package/dist/index.js +5027 -421
  9. package/package.json +8 -3
  10. package/src/cli.test.ts +55 -0
  11. package/src/cli.ts +601 -217
  12. package/src/daemon/args.ts +8 -1
  13. package/src/daemon/fatal-error.test.ts +104 -0
  14. package/src/daemon/fatal-error.ts +48 -0
  15. package/src/daemon/models.ts +119 -39
  16. package/src/daemon/wallet/auto-refill.ts +4 -6
  17. package/src/daemon/wallet/cleanup.test.ts +168 -0
  18. package/src/daemon/wallet/cleanup.ts +104 -0
  19. package/src/daemon/wallet/coco-client.npc.test.ts +252 -0
  20. package/src/daemon/wallet/coco-client.test.ts +669 -0
  21. package/src/daemon/wallet/coco-client.ts +1628 -0
  22. package/src/daemon/wallet/cocod-client.ts +135 -3
  23. package/src/daemon/wallet/diagnostics.test.ts +378 -0
  24. package/src/daemon/wallet/diagnostics.ts +471 -0
  25. package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
  26. package/src/daemon/wallet/migration.test.ts +171 -0
  27. package/src/daemon/wallet/migration.ts +154 -0
  28. package/src/daemon/wallet/paths.ts +33 -0
  29. package/src/daemon/wallet/receive-dedup.test.ts +238 -0
  30. package/src/daemon/wallet/receive-dedup.ts +384 -0
  31. package/src/daemon/wallet/wallet-state.ts +75 -0
  32. package/src/integrations/claudecode.ts +2 -1
  33. package/src/integrations/hermes.ts +46 -6
  34. package/src/integrations/openclaw.ts +3 -2
  35. package/src/integrations/opencode.ts +3 -2
  36. package/src/integrations/pi.ts +3 -2
  37. package/src/integrations/registry.ts +7 -5
  38. package/src/start-daemon.ts +189 -23
  39. package/src/utils/clients.ts +21 -0
  40. package/src/utils/config.ts +11 -0
  41. package/src/utils/daemon-client.ts +104 -25
  42. package/src/utils/logger.ts +42 -28
  43. package/tests/integrations/hermes.test.ts +42 -5
  44. package/tests/utils/daemon-client.test.ts +61 -0
  45. package/tests/wallet/short-keyset-token.test.ts +106 -0
@@ -0,0 +1,384 @@
1
+ import { createHash } from "crypto";
2
+ import type { ReceiveOperation } from "@cashu/coco-core";
3
+ import type { Database } from "bun:sqlite";
4
+
5
+ const TOKEN_TABLE = "routstrd_receive_tokens";
6
+ const MAINTENANCE_TABLE = "routstrd_wallet_maintenance";
7
+ const RECONCILE_BACKUP_KEY = "receive_reconcile_v1_backup";
8
+
9
+ export type StoredReceiveTokenState = "processing" | "succeeded" | "failed";
10
+
11
+ export interface StoredReceiveTokenRow {
12
+ tokenHash: string;
13
+ state: StoredReceiveTokenState;
14
+ operationId: string | null;
15
+ error: string | null;
16
+ }
17
+
18
+ export interface ReceiveTokenReservation {
19
+ acquired: boolean;
20
+ tokenHash: string;
21
+ existing?: StoredReceiveTokenRow;
22
+ }
23
+
24
+ export interface ReceiveReconcileResult {
25
+ executing: number;
26
+ uniqueGroups: number;
27
+ rolledBack: number;
28
+ retained: number;
29
+ unresolved: number;
30
+ recoveryOperationIds: string[];
31
+ backupPath?: string;
32
+ }
33
+
34
+ interface SerializedBlindedMessageLike {
35
+ amount: number;
36
+ id: string;
37
+ B_: string;
38
+ }
39
+
40
+ interface SerializedOutput {
41
+ blindedMessage: SerializedBlindedMessageLike;
42
+ secret: string;
43
+ }
44
+
45
+ interface SerializedOutputDataLike {
46
+ keep: SerializedOutput[];
47
+ send: SerializedOutput[];
48
+ }
49
+
50
+ type ExecutingReceive = Extract<ReceiveOperation, { state: "executing" }>;
51
+
52
+ export interface ReceiveReconcileSource {
53
+ listExecuting(): Promise<ReceiveOperation[]>;
54
+ checkProofStates(operation: ExecutingReceive): Promise<Array<{ state: string }>>;
55
+ restoreOutputs(
56
+ mintUrl: string,
57
+ outputs: SerializedBlindedMessageLike[],
58
+ ): Promise<Array<{ B_: string }>>;
59
+ hasSavedOutputs(operation: ExecutingReceive): Promise<boolean>;
60
+ rollBack(operation: ExecutingReceive, reason: string): Promise<void>;
61
+ }
62
+
63
+ export function hashReceiveToken(token: string): string {
64
+ return createHash("sha256").update(token).digest("hex");
65
+ }
66
+
67
+ export function initReceiveDedupSchema(database: Database): void {
68
+ database.exec(`
69
+ CREATE TABLE IF NOT EXISTS ${TOKEN_TABLE} (
70
+ tokenHash TEXT PRIMARY KEY,
71
+ state TEXT NOT NULL CHECK (state IN ('processing', 'succeeded', 'failed')),
72
+ operationId TEXT,
73
+ error TEXT,
74
+ createdAt INTEGER NOT NULL,
75
+ updatedAt INTEGER NOT NULL
76
+ );
77
+ CREATE TABLE IF NOT EXISTS ${MAINTENANCE_TABLE} (
78
+ key TEXT PRIMARY KEY,
79
+ value TEXT NOT NULL,
80
+ updatedAt INTEGER NOT NULL
81
+ );
82
+ `);
83
+ }
84
+
85
+ export function listProcessingReceiveTokens(database: Database): StoredReceiveTokenRow[] {
86
+ return database
87
+ .query(
88
+ `SELECT tokenHash, state, operationId, error
89
+ FROM ${TOKEN_TABLE} WHERE state = 'processing'`,
90
+ )
91
+ .all() as StoredReceiveTokenRow[];
92
+ }
93
+
94
+ export function deleteReceiveTokenReservation(database: Database, tokenHash: string): void {
95
+ database.query(`DELETE FROM ${TOKEN_TABLE} WHERE tokenHash = ?`).run(tokenHash);
96
+ }
97
+
98
+ export function clearInterruptedReceiveReservations(database: Database): number {
99
+ const result = database
100
+ .query(
101
+ `DELETE FROM ${TOKEN_TABLE}
102
+ WHERE state = 'processing' AND operationId IS NULL`,
103
+ )
104
+ .run();
105
+ return result.changes;
106
+ }
107
+
108
+ export function reserveReceiveToken(database: Database, token: string): ReceiveTokenReservation {
109
+ const tokenHash = hashReceiveToken(token);
110
+ const now = Math.floor(Date.now() / 1000);
111
+ const result = database
112
+ .query(
113
+ `INSERT OR IGNORE INTO ${TOKEN_TABLE}
114
+ (tokenHash, state, operationId, error, createdAt, updatedAt)
115
+ VALUES (?, 'processing', NULL, NULL, ?, ?)`,
116
+ )
117
+ .run(tokenHash, now, now);
118
+
119
+ if (result.changes > 0) return { acquired: true, tokenHash };
120
+
121
+ const existing = database
122
+ .query(
123
+ `SELECT tokenHash, state, operationId, error
124
+ FROM ${TOKEN_TABLE} WHERE tokenHash = ?`,
125
+ )
126
+ .get(tokenHash) as StoredReceiveTokenRow | null;
127
+ return { acquired: false, tokenHash, ...(existing ? { existing } : {}) };
128
+ }
129
+
130
+ export function releaseReceiveToken(database: Database, tokenHash: string): void {
131
+ database
132
+ .query(`DELETE FROM ${TOKEN_TABLE} WHERE tokenHash = ? AND operationId IS NULL`)
133
+ .run(tokenHash);
134
+ }
135
+
136
+ export function updateReceiveToken(
137
+ database: Database,
138
+ tokenHash: string,
139
+ update: { state: StoredReceiveTokenState; operationId?: string; error?: string },
140
+ ): void {
141
+ database
142
+ .query(
143
+ `UPDATE ${TOKEN_TABLE}
144
+ SET state = ?, operationId = COALESCE(?, operationId), error = ?, updatedAt = ?
145
+ WHERE tokenHash = ?`,
146
+ )
147
+ .run(
148
+ update.state,
149
+ update.operationId ?? null,
150
+ update.error ?? null,
151
+ Math.floor(Date.now() / 1000),
152
+ tokenHash,
153
+ );
154
+ }
155
+
156
+ export function receiveInputFingerprint(operation: ReceiveOperation): string {
157
+ // Legacy cleanup is intentionally stricter than new-request token hashing:
158
+ // only operations with the exact same proof material are grouped. A shared
159
+ // secret alone is not enough evidence to discard an operation.
160
+ const proofs = operation.inputProofs
161
+ .map((proof) => ({
162
+ amount: proof.amount,
163
+ id: proof.id,
164
+ secret: proof.secret,
165
+ C: proof.C,
166
+ witness: proof.witness ?? null,
167
+ }))
168
+ .sort((left, right) =>
169
+ JSON.stringify(left).localeCompare(JSON.stringify(right)),
170
+ );
171
+ return createHash("sha256")
172
+ .update(JSON.stringify([operation.mintUrl, operation.unit, proofs]))
173
+ .digest("hex");
174
+ }
175
+
176
+ export function groupExecutingReceives(
177
+ operations: ReceiveOperation[],
178
+ ): ExecutingReceive[][] {
179
+ const groups = new Map<string, ExecutingReceive[]>();
180
+ for (const operation of operations) {
181
+ if (operation.state !== "executing") continue;
182
+ const key = receiveInputFingerprint(operation);
183
+ const group = groups.get(key) ?? [];
184
+ group.push(operation);
185
+ groups.set(key, group);
186
+ }
187
+ return [...groups.values()].map((group) =>
188
+ group.sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)),
189
+ );
190
+ }
191
+
192
+ function outputData(operation: ExecutingReceive): SerializedOutputDataLike | null {
193
+ const value = operation.outputData as unknown;
194
+ if (!value || typeof value !== "object") return null;
195
+ const candidate = value as Partial<SerializedOutputDataLike>;
196
+ if (!Array.isArray(candidate.keep) || !Array.isArray(candidate.send)) return null;
197
+ return { keep: candidate.keep, send: candidate.send };
198
+ }
199
+
200
+ function outputBlindedMessages(operation: ExecutingReceive): SerializedBlindedMessageLike[] {
201
+ const data = outputData(operation);
202
+ if (!data) return [];
203
+ return [...data.keep, ...data.send]
204
+ .map((entry) => entry?.blindedMessage)
205
+ .filter((message): message is SerializedBlindedMessageLike =>
206
+ Boolean(
207
+ message &&
208
+ typeof message.B_ === "string" &&
209
+ message.B_ &&
210
+ typeof message.id === "string" &&
211
+ Number.isFinite(message.amount),
212
+ ),
213
+ );
214
+ }
215
+
216
+ async function rollBackOthers(
217
+ source: ReceiveReconcileSource,
218
+ group: ExecutingReceive[],
219
+ retainedIds: Set<string>,
220
+ reason: string,
221
+ ): Promise<number> {
222
+ let count = 0;
223
+ for (const operation of group) {
224
+ if (retainedIds.has(operation.id)) continue;
225
+ await source.rollBack(operation, reason);
226
+ count++;
227
+ }
228
+ return count;
229
+ }
230
+
231
+ /**
232
+ * Reduce legacy executing receives before coco-core's per-operation recovery.
233
+ *
234
+ * No state is changed unless the evidence is conclusive:
235
+ * - saved local outputs identify the successful operation;
236
+ * - all inputs are unspent, so one deterministic retry is sufficient; or
237
+ * - all inputs are spent and restore identifies an output owner (or confirms
238
+ * that none of the stored deterministic outputs exist).
239
+ *
240
+ * Unreachable mints, malformed output data, partial restore ownership, and
241
+ * mixed proof states are left untouched for normal recovery/manual review.
242
+ */
243
+ export async function reconcileExecutingReceives(
244
+ source: ReceiveReconcileSource,
245
+ ): Promise<ReceiveReconcileResult> {
246
+ const executing = (await source.listExecuting()).filter(
247
+ (operation): operation is ExecutingReceive => operation.state === "executing",
248
+ );
249
+ const groups = groupExecutingReceives(executing);
250
+ const result: ReceiveReconcileResult = {
251
+ executing: executing.length,
252
+ uniqueGroups: groups.length,
253
+ rolledBack: 0,
254
+ retained: 0,
255
+ unresolved: 0,
256
+ recoveryOperationIds: [],
257
+ };
258
+
259
+ for (const group of groups) {
260
+ try {
261
+ const savedOwners = new Set<string>();
262
+ for (const operation of group) {
263
+ if (await source.hasSavedOutputs(operation)) savedOwners.add(operation.id);
264
+ }
265
+ if (savedOwners.size > 0) {
266
+ result.rolledBack += await rollBackOthers(
267
+ source,
268
+ group,
269
+ savedOwners,
270
+ "Duplicate receive: outputs already persisted by another operation",
271
+ );
272
+ result.retained += savedOwners.size;
273
+ result.recoveryOperationIds.push(...savedOwners);
274
+ continue;
275
+ }
276
+
277
+ const states = await source.checkProofStates(group[0]!);
278
+ const completeStates = states.length === group[0]!.inputProofs.length;
279
+ const allUnspent = completeStates && states.every((state) => state.state === "UNSPENT");
280
+ const allSpent = completeStates && states.every((state) => state.state === "SPENT");
281
+
282
+ if (allUnspent) {
283
+ const canonical = group.find((operation) => outputBlindedMessages(operation).length > 0);
284
+ if (!canonical) {
285
+ result.unresolved += group.length;
286
+ continue;
287
+ }
288
+ result.rolledBack += await rollBackOthers(
289
+ source,
290
+ group,
291
+ new Set([canonical.id]),
292
+ "Duplicate receive: identical inputs retained by canonical operation",
293
+ );
294
+ result.retained++;
295
+ result.recoveryOperationIds.push(canonical.id);
296
+ continue;
297
+ }
298
+
299
+ if (!allSpent) {
300
+ result.unresolved += group.length;
301
+ continue;
302
+ }
303
+
304
+ const outputOwners = new Map<
305
+ string,
306
+ { message: SerializedBlindedMessageLike; owners: Set<string> }
307
+ >();
308
+ let malformed = false;
309
+ for (const operation of group) {
310
+ const outputs = outputBlindedMessages(operation);
311
+ if (outputs.length === 0) {
312
+ malformed = true;
313
+ continue;
314
+ }
315
+ for (const output of outputs) {
316
+ const entry = outputOwners.get(output.B_) ?? {
317
+ message: output,
318
+ owners: new Set<string>(),
319
+ };
320
+ entry.owners.add(operation.id);
321
+ outputOwners.set(output.B_, entry);
322
+ }
323
+ }
324
+ if (malformed || outputOwners.size === 0) {
325
+ result.unresolved += group.length;
326
+ continue;
327
+ }
328
+
329
+ const restored = await source.restoreOutputs(
330
+ group[0]!.mintUrl,
331
+ [...outputOwners.values()].map((entry) => entry.message),
332
+ );
333
+ const restoredOwners = new Set<string>();
334
+ for (const output of restored) {
335
+ for (const owner of outputOwners.get(output.B_)?.owners ?? []) {
336
+ restoredOwners.add(owner);
337
+ }
338
+ }
339
+
340
+ if (restoredOwners.size === 0) {
341
+ for (const operation of group) {
342
+ await source.rollBack(
343
+ operation,
344
+ "Recovered: input proofs spent without recoverable outputs",
345
+ );
346
+ result.rolledBack++;
347
+ }
348
+ continue;
349
+ }
350
+
351
+ result.rolledBack += await rollBackOthers(
352
+ source,
353
+ group,
354
+ restoredOwners,
355
+ "Duplicate receive: recoverable outputs belong to another operation",
356
+ );
357
+ result.retained += restoredOwners.size;
358
+ result.recoveryOperationIds.push(...restoredOwners);
359
+ } catch {
360
+ // A network, parsing, or persistence failure is not evidence that an
361
+ // operation is safe to abandon. Leave the complete group untouched.
362
+ result.unresolved += group.length;
363
+ }
364
+ }
365
+
366
+ return result;
367
+ }
368
+
369
+ export function getReceiveReconcileBackup(database: Database): string | null {
370
+ const row = database
371
+ .query(`SELECT value FROM ${MAINTENANCE_TABLE} WHERE key = ?`)
372
+ .get(RECONCILE_BACKUP_KEY) as { value: string } | null;
373
+ return row?.value ?? null;
374
+ }
375
+
376
+ export function setReceiveReconcileBackup(database: Database, path: string): void {
377
+ database
378
+ .query(
379
+ `INSERT INTO ${MAINTENANCE_TABLE} (key, value, updatedAt)
380
+ VALUES (?, ?, ?)
381
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updatedAt = excluded.updatedAt`,
382
+ )
383
+ .run(RECONCILE_BACKUP_KEY, path, Math.floor(Date.now() / 1000));
384
+ }
@@ -0,0 +1,75 @@
1
+ import { existsSync, readFileSync, statSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ /**
5
+ * Pure wallet-migration state detection, shared by the actual migration and by
6
+ * the doctor diagnostics so the two cannot disagree about startup behavior.
7
+ */
8
+
9
+ export type WalletState = "absent" | "database-only" | "initialized";
10
+
11
+ export function walletState(configPath: string, dbPath: string): WalletState {
12
+ const hasConfig = existsSync(configPath);
13
+ const hasDb = existsSync(dbPath);
14
+ if (hasConfig) return "initialized";
15
+ if (hasDb) return "database-only";
16
+ return "absent";
17
+ }
18
+
19
+ export function filesEqual(left: string, right: string): boolean {
20
+ if (!existsSync(left) || !existsSync(right)) return false;
21
+ if (statSync(left).size !== statSync(right).size) return false;
22
+ return readFileSync(left).equals(readFileSync(right));
23
+ }
24
+
25
+ export type WalletMigrationClass =
26
+ | { kind: "fresh" }
27
+ | { kind: "already-current" }
28
+ | { kind: "migrate" }
29
+ | { kind: "conflict" }
30
+ | { kind: "database-only"; targetState: WalletState; sourceState: WalletState }
31
+ | { kind: "orphaned-sidecars" };
32
+
33
+ /**
34
+ * Classify the two wallet locations exactly the way `migrateLegacyWallet`
35
+ * decides what to do. This is the single source of truth for startup behavior;
36
+ * `migrateLegacyWallet` maps it onto actions/errors and the doctor maps it onto
37
+ * verdict text.
38
+ */
39
+ export function classifyWalletMigration(
40
+ targetDir: string,
41
+ sourceDir: string,
42
+ ): WalletMigrationClass {
43
+ const targetConfig = join(targetDir, "config.json");
44
+ const targetDb = join(targetDir, "coco.db");
45
+ const sourceConfig = join(sourceDir, "config.json");
46
+ const sourceDb = join(sourceDir, "coco.db");
47
+ const sourceWal = `${sourceDb}-wal`;
48
+ const sourceShm = `${sourceDb}-shm`;
49
+
50
+ const targetState = walletState(targetConfig, targetDb);
51
+ const sourceState = walletState(sourceConfig, sourceDb);
52
+
53
+ // config.json is sufficient for a newly initialized wallet; coco.db is
54
+ // created on first open. A database without its mnemonic is never usable.
55
+ if (targetState === "initialized" && sourceState === "initialized") {
56
+ // A prior migration may have committed successfully but failed to remove
57
+ // its source files. Identical leftovers are safe; divergent wallets are not.
58
+ const configsMatch = filesEqual(targetConfig, sourceConfig);
59
+ const databasesMatch =
60
+ !existsSync(targetDb) && !existsSync(sourceDb)
61
+ ? true
62
+ : filesEqual(targetDb, sourceDb);
63
+ if (configsMatch && databasesMatch) return { kind: "already-current" };
64
+ return { kind: "conflict" };
65
+ }
66
+ if (targetState === "initialized") return { kind: "already-current" };
67
+ if (!existsSync(sourceDb) && (existsSync(sourceWal) || existsSync(sourceShm))) {
68
+ return { kind: "orphaned-sidecars" };
69
+ }
70
+ if (targetState === "database-only" || sourceState === "database-only") {
71
+ return { kind: "database-only", targetState, sourceState };
72
+ }
73
+ if (sourceState === "absent") return { kind: "fresh" };
74
+ return { kind: "migrate" };
75
+ }
@@ -27,7 +27,8 @@ export async function installClaudeCodeIntegration(
27
27
  settings = JSON.parse(content);
28
28
  }
29
29
  } catch (error) {
30
- console.error(`Error reading ${configPath}, creating new one.`);
30
+ console.error(`Failed to read or parse ${configPath}; leaving it unchanged:`, error);
31
+ return;
31
32
  }
32
33
 
33
34
  if (!settings.env) {
@@ -17,6 +17,13 @@ interface HermesCustomProvider {
17
17
  [key: string]: unknown;
18
18
  }
19
19
 
20
+ /** Hermes references a custom provider as `custom:<slug>`, where the slug is
21
+ * the provider name lowercased with runs of whitespace collapsed to hyphens
22
+ * (e.g. `Routstr (routstr.ft.hn)` -> `custom:routstr-(routstr.ft.hn)`). */
23
+ function hermesProviderRef(name: string): string {
24
+ return `custom:${name.toLowerCase().replace(/\s+/g, "-")}`;
25
+ }
26
+
20
27
  export function mergeHermesConfig(
21
28
  content: string,
22
29
  routstr: HermesRoutstrConfig,
@@ -29,8 +36,9 @@ export function mergeHermesConfig(
29
36
  const urlDisplay = routstr.baseUrl
30
37
  .replace(/\/v1$/, "")
31
38
  .replace(/^https?:\/\//, "");
39
+ const providerName = `Routstr (${urlDisplay})`;
32
40
  const provider = {
33
- name: `Routstr (${urlDisplay})`,
41
+ name: providerName,
34
42
  base_url: routstr.baseUrl,
35
43
  api_key: routstr.apiKey,
36
44
  model: routstr.defaultModel,
@@ -46,17 +54,48 @@ export function mergeHermesConfig(
46
54
  });
47
55
  }
48
56
 
57
+ // Replace an existing Routstr custom provider in place so that re-running
58
+ // `clients add --hermes` after changing the daemon URL updates base_url /
59
+ // api_key / model instead of silently keeping the stale entry. We only touch
60
+ // the first matching entry; any other providers are left as-is.
49
61
  const existingConfig = document.toJS() as {
50
62
  custom_providers?: HermesCustomProvider[];
51
63
  };
52
64
  const existingProviders = existingConfig.custom_providers;
53
- const providers = Array.isArray(existingProviders) ? existingProviders : [];
54
- if (providers.some((item) => item.name?.startsWith("Routstr ("))) {
55
- return content;
65
+ const providers = Array.isArray(existingProviders)
66
+ ? existingProviders.slice()
67
+ : [];
68
+ const routstrIndex = providers.findIndex(
69
+ (item) => typeof item?.name === "string" && item.name.startsWith("Routstr ("),
70
+ );
71
+ let previousName: string | undefined;
72
+ if (routstrIndex >= 0) {
73
+ previousName = providers[routstrIndex]!.name;
74
+ providers[routstrIndex] = { ...providers[routstrIndex], ...provider };
75
+ } else {
76
+ providers.push(provider);
56
77
  }
57
- providers.push(provider);
58
78
  document.set("custom_providers", providers);
59
79
 
80
+ // If we renamed the Routstr provider, keep `model.provider` pointing at it so
81
+ // Hermes doesn't end up referencing a provider that no longer exists. We only
82
+ // adjust configs whose default model already routes through a Routstr custom
83
+ // provider, leaving any other selection untouched.
84
+ if (
85
+ !isNewConfig &&
86
+ previousName &&
87
+ previousName !== providerName &&
88
+ document.hasIn(["model", "provider"])
89
+ ) {
90
+ const currentRef = document.getIn(["model", "provider"]);
91
+ if (
92
+ typeof currentRef === "string" &&
93
+ currentRef === hermesProviderRef(previousName)
94
+ ) {
95
+ document.setIn(["model", "provider"], hermesProviderRef(providerName));
96
+ }
97
+ }
98
+
60
99
  return document.toString();
61
100
  }
62
101
 
@@ -99,7 +138,8 @@ export async function installHermesIntegration(
99
138
  content = await readFile(configPath, "utf-8");
100
139
  }
101
140
  } catch (error) {
102
- console.error(`Error reading ${configPath}, creating new one.`);
141
+ console.error(`Failed to read ${configPath}; leaving it unchanged:`, error);
142
+ return;
103
143
  }
104
144
 
105
145
  let newContent: string;
@@ -67,8 +67,9 @@ export async function installOpenClawIntegration(
67
67
  const content = await readFile(configPath, "utf-8");
68
68
  openclawConfig = JSON.parse(content) as OpenClawConfig;
69
69
  }
70
- } catch {
71
- openclawConfig = {};
70
+ } catch (error) {
71
+ console.error(`Failed to read or parse ${configPath}; leaving it unchanged:`, error);
72
+ return;
72
73
  }
73
74
 
74
75
  if (!openclawConfig.models) {
@@ -40,8 +40,9 @@ export async function installOpencodeIntegration(
40
40
  } else {
41
41
  opencodeConfig = { provider: {} };
42
42
  }
43
- } catch {
44
- opencodeConfig = { provider: {} };
43
+ } catch (error) {
44
+ console.error(`Failed to read or parse ${configPath}; leaving it unchanged:`, error);
45
+ return;
45
46
  }
46
47
 
47
48
  if (!opencodeConfig.provider) {
@@ -40,8 +40,9 @@ export async function installPiIntegration(
40
40
  const content = await readFile(configPath, "utf-8");
41
41
  piConfig = JSON.parse(content) as PiConfig;
42
42
  }
43
- } catch {
44
- piConfig = {};
43
+ } catch (error) {
44
+ console.error(`Failed to read or parse ${configPath}; leaving it unchanged:`, error);
45
+ return;
45
46
  }
46
47
 
47
48
  if (!piConfig.providers) {
@@ -6,6 +6,8 @@ import { installOpenClawIntegration } from "./openclaw";
6
6
  import { installClaudeCodeIntegration } from "./claudecode";
7
7
  import { installHermesIntegration } from "./hermes";
8
8
 
9
+ const HOME = process.env.HOME || process.env.USERPROFILE || "";
10
+
9
11
  export interface IntegrationConfig {
10
12
  clientId: string;
11
13
  name: string;
@@ -28,27 +30,27 @@ export const CLIENT_CONFIGS: Record<string, IntegrationConfig> = {
28
30
  opencode: {
29
31
  clientId: "opencode",
30
32
  name: "OpenCode",
31
- configPath: join(process.env.HOME || "", ".config/opencode/opencode.json"),
33
+ configPath: join(HOME, ".config/opencode/opencode.json"),
32
34
  },
33
35
  "pi-agent": {
34
36
  clientId: "pi-agent",
35
37
  name: "Pi Agent",
36
- configPath: join(process.env.HOME || "", ".pi/agent/models.json"),
38
+ configPath: join(HOME, ".pi/agent/models.json"),
37
39
  },
38
40
  openclaw: {
39
41
  clientId: "openclaw",
40
42
  name: "OpenClaw",
41
- configPath: join(process.env.HOME || "", ".openclaw/openclaw.json"),
43
+ configPath: join(HOME, ".openclaw/openclaw.json"),
42
44
  },
43
45
  "claude-code": {
44
46
  clientId: "claude-code",
45
47
  name: "Claude Code",
46
- configPath: join(process.env.HOME || "", ".claude/settings.json"),
48
+ configPath: join(HOME, ".claude/settings.json"),
47
49
  },
48
50
  hermes: {
49
51
  clientId: "hermes",
50
52
  name: "Hermes",
51
- configPath: join(process.env.HOME || "", ".hermes/config.yaml"),
53
+ configPath: join(HOME, ".hermes/config.yaml"),
52
54
  },
53
55
  };
54
56