routstrd 0.2.8 → 0.2.10

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 CHANGED
@@ -23,44 +23,41 @@ curl -fsSL https://bun.com/install | bash
23
23
 
24
24
  ## Installation
25
25
 
26
- ### From Source
26
+ ### Step 1: Install
27
27
 
28
+ **Global with bun:**
28
29
  ```sh
30
+ bun i -g routstrd
31
+ ```
32
+
33
+ **OR - From source:**
34
+ ```sh
35
+ git clone https://github.com/routstr/routstrd.git
29
36
  cd routstrd
30
37
  bun install
31
38
  bun link
32
- routstrd onboard
33
39
  ```
34
40
 
35
- ## Usage
36
-
37
- ### Initialize
38
-
39
- Initialize routstrd (creates config directory and sets up cocod):
41
+ ### Step 2: Setup & Fund
40
42
 
41
43
  ```sh
42
44
  routstrd onboard
45
+ routstrd receive <cashu> # receive a Cashu token
46
+ routstrd receive 2100 # to top up 2100 sats with lightning
43
47
  ```
44
48
 
45
- This will:
46
- - Create `~/.routstrd/` directory
47
- - Create config file at `~/.routstrd/config.json`
48
- - Run `cocod init` to set up the wallet
49
-
49
+ ### Step 3: Integrate with Claude Code
50
50
 
51
- Then fund with Cashu/Lightning:
52
51
  ```sh
53
- cocod receive cashu <token>
54
- ```
55
- or
56
- ```sh
57
- cocod receive bolt11 <amount>
52
+ routstrd clients add --claude-code # or --pi-agent / --opencode
58
53
  ```
59
54
 
60
- ### That's it! You can start using it on http://localhost:8008
55
+ ## Use Routstrd Skill
56
+
57
+ > **Tip:** You can also install the [routstrd skill](https://github.com/Routstr/routstrd/blob/main/SKILL.md) so the agent can manage routstrd for you.
61
58
 
62
59
  ## More Commands
63
- ###Start Daemon
60
+ ### Start Daemon
64
61
 
65
62
  Start the background daemon:
66
63
 
package/SKILL.md CHANGED
@@ -111,6 +111,7 @@ List and manage API clients (subcommand required).
111
111
 
112
112
  List all registered clients with their ID, name, API key, and creation date.
113
113
 
114
+
114
115
  #### `routstrd clients add`
115
116
 
116
117
  Add a new client or set up a client integration.
@@ -123,28 +124,53 @@ Add a new client or set up a client integration.
123
124
  | `--pi-agent` | Set up Pi Agent integration |
124
125
  | `--claude-code` | Set up Claude Code integration |
125
126
 
126
- Set up a specific client integration (creates API key and writes config to the client's config file):
127
-
128
127
  ```sh
129
- routstrd clients add --opencode
130
- routstrd clients add --claude-code
131
- routstrd clients add --pi-agent
132
- routstrd clients add --openclaw
128
+ routstrd clients add --opencode --pi-agent --claude-code # multiple integrations
129
+ routstrd clients add -n "My App" # generic client
133
130
  ```
134
131
 
135
- You can also set up multiple integrations at once:
132
+ Returns the client ID and API key for use with the OpenAI-compatible API.
136
133
 
137
- ```sh
138
- routstrd clients add --opencode --pi-agent --claude-code
139
- ```
134
+ #### `routstrd clients delete <id>`
135
+
136
+ Delete a registered client by its ID.
137
+
138
+ ### `routstrd npubs`
140
139
 
141
- Add a generic client manually:
140
+
141
+ Manage admin npubs (subcommand required).
142
+
143
+ | Command | Description |
144
+ |---------|-------------|
145
+ | `routstrd npubs list` | List configured admin npubs |
146
+ | `routstrd npubs add <npub>` | Add an admin npub (accepts hex or npub1...) |
147
+ | `routstrd npubs delete <npub>` | Delete an admin npub |
148
+
149
+ ### `routstrd remote <url>`
150
+
151
+ Configure a remote daemon URL. Generates a Nostr identity (nsec/npub) for NIP-98 authentication automatically.
142
152
 
143
153
  ```sh
144
- routstrd clients add -n "My App"
154
+ routstrd remote https://your-remote-daemon.com
145
155
  ```
146
156
 
147
- Returns the client ID and API key for use with the OpenAI-compatible API.
157
+ ### `routstrd refresh`
158
+
159
+ Refresh routstr21 models from Nostr and re-run integrations for all registered clients.
160
+
161
+ | Field | Type | Default | Description |
162
+ |-------|------|---------|-------------|
163
+ | `port` | number | 8008 | Daemon HTTP port |
164
+ | `provider` | string\|null | null | Default provider URL |
165
+ | `daemonUrl` | string\|null | null | Remote daemon URL |
166
+ | `nsec` | string\|null | null | Nostr secret key for NIP-98 auth |
167
+ | `cocodPath` | string\|null | null | Custom path to cocod executable |
168
+ | `mode` | string | `"apikeys"` | Client mode (`apikeys` or `xcashu`) |
169
+
170
+ | Variable | Default | Description |
171
+ |----------|---------|-------------|
172
+ | `ROUTSTRD_DIR` | `~/.routstrd` | Config directory |
173
+ | `COCOD_DIR` | `~/.cocod` | Wallet config directory |
148
174
 
149
175
  ### `routstrd mode`
150
176
 
@@ -266,6 +292,13 @@ Config file: `~/.routstrd/config.json`
266
292
  | `ROUTSTRD_SOCKET` | `~/.routstrd/routstrd.sock` | IPC socket path |
267
293
  | `ROUTSTRD_PID` | `~/.routstrd/routstrd.pid` | PID file path |
268
294
 
295
+ ## Remote Mode
296
+
297
+ When `daemonUrl` is configured, commands connect to a remote daemon instead of a local one:
298
+ - Client names are suffixed with the last 7 chars of your npub
299
+ - All requests are automatically NIP-98 signed using your local nsec
300
+ - Local-only commands (`onboard`, `start`, `restart`, `mode`, `logs`, `service`) are disabled
301
+
269
302
  ## Pi Integration
270
303
 
271
304
  When `routstrd onboard` runs, it automatically configures a `routstr` provider in `pi`'s `models.json` with an OpenAI-compatible base URL and API key. This allows pi (the AI coding agent) to use Routstr providers seamlessly.
@@ -34900,6 +34900,102 @@ init_cashu_ts_es();
34900
34900
 
34901
34901
  // src/daemon/wallet/cocod-client.ts
34902
34902
  import { createHash } from "crypto";
34903
+
34904
+ // src/utils/process-lock.ts
34905
+ import { randomUUID } from "crypto";
34906
+ import { mkdir as mkdir3, readFile, rm, stat, writeFile } from "fs/promises";
34907
+ import { dirname } from "path";
34908
+ function delay(ms) {
34909
+ return new Promise((resolve) => setTimeout(resolve, ms));
34910
+ }
34911
+ function isProcessRunning(pid) {
34912
+ if (!Number.isFinite(pid) || pid <= 0) {
34913
+ return false;
34914
+ }
34915
+ try {
34916
+ process.kill(pid, 0);
34917
+ return true;
34918
+ } catch (error) {
34919
+ const code = error.code;
34920
+ return code === "EPERM";
34921
+ }
34922
+ }
34923
+ async function readLockOwner(lockDir) {
34924
+ try {
34925
+ const raw = await readFile(`${lockDir}/owner.json`, "utf8");
34926
+ const parsed = JSON.parse(raw);
34927
+ if (typeof parsed.pid === "number" && typeof parsed.createdAt === "number") {
34928
+ return {
34929
+ pid: parsed.pid,
34930
+ createdAt: parsed.createdAt,
34931
+ token: typeof parsed.token === "string" ? parsed.token : undefined
34932
+ };
34933
+ }
34934
+ } catch {
34935
+ }
34936
+ return null;
34937
+ }
34938
+ async function isLockStale(lockDir, staleAfterMs) {
34939
+ const owner = await readLockOwner(lockDir);
34940
+ if (owner) {
34941
+ return !isProcessRunning(owner.pid) || Date.now() - owner.createdAt > staleAfterMs;
34942
+ }
34943
+ try {
34944
+ const info = await stat(lockDir);
34945
+ return Date.now() - info.mtimeMs > staleAfterMs;
34946
+ } catch {
34947
+ return false;
34948
+ }
34949
+ }
34950
+ async function acquireCrossProcessLock(lockDir, options = {}) {
34951
+ const acquireTimeoutMs = options.acquireTimeoutMs ?? 120000;
34952
+ const retryIntervalMs = options.retryIntervalMs ?? 100;
34953
+ const staleAfterMs = options.staleAfterMs ?? 120000;
34954
+ const deadline = Date.now() + acquireTimeoutMs;
34955
+ await mkdir3(dirname(lockDir), { recursive: true });
34956
+ while (true) {
34957
+ try {
34958
+ await mkdir3(lockDir);
34959
+ const token = randomUUID();
34960
+ const owner = { pid: process.pid, createdAt: Date.now(), token };
34961
+ await writeFile(`${lockDir}/owner.json`, JSON.stringify(owner), "utf8");
34962
+ let released = false;
34963
+ return async () => {
34964
+ if (released)
34965
+ return;
34966
+ released = true;
34967
+ const currentOwner = await readLockOwner(lockDir);
34968
+ if (currentOwner?.token === token) {
34969
+ await rm(lockDir, { recursive: true, force: true });
34970
+ }
34971
+ };
34972
+ } catch (error) {
34973
+ const code = error.code;
34974
+ if (code !== "EEXIST") {
34975
+ throw error;
34976
+ }
34977
+ if (await isLockStale(lockDir, staleAfterMs)) {
34978
+ options.log?.(`Removing stale lock at ${lockDir}`);
34979
+ await rm(lockDir, { recursive: true, force: true });
34980
+ continue;
34981
+ }
34982
+ if (Date.now() >= deadline) {
34983
+ throw new Error(`Timed out waiting to acquire lock ${lockDir}`);
34984
+ }
34985
+ await delay(retryIntervalMs);
34986
+ }
34987
+ }
34988
+ }
34989
+ async function withCrossProcessLock(lockDir, fn, options = {}) {
34990
+ const release = await acquireCrossProcessLock(lockDir, options);
34991
+ try {
34992
+ return await fn();
34993
+ } finally {
34994
+ await release();
34995
+ }
34996
+ }
34997
+
34998
+ // src/daemon/wallet/cocod-client.ts
34903
34999
  var DEFAULT_CONFIG_DIR = process.env.COCOD_DIR || `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod`;
34904
35000
  var DEFAULT_SOCKET_PATH = process.env.COCOD_SOCKET || `${DEFAULT_CONFIG_DIR}/cocod.sock`;
34905
35001
 
@@ -34929,7 +35025,7 @@ function parseMintList(output4) {
34929
35025
  return (output4 || "").split(`
34930
35026
  `).map((line) => line.trim()).filter(Boolean);
34931
35027
  }
34932
- function delay(ms) {
35028
+ function delay2(ms) {
34933
35029
  return new Promise((resolve) => setTimeout(resolve, ms));
34934
35030
  }
34935
35031
  function toErrorText(value) {
@@ -34951,6 +35047,7 @@ function tokenFingerprint(token) {
34951
35047
  function createCocodClient(options = {}) {
34952
35048
  const executable = resolveCocodExecutable(options.cocodPath);
34953
35049
  const socketPath = options.socketPath || DEFAULT_SOCKET_PATH;
35050
+ const startupLockPath = `${socketPath}.startup.lock`;
34954
35051
  const fetchImpl = options.fetchImpl || fetch;
34955
35052
  const pollIntervalMs = options.pollIntervalMs ?? 100;
34956
35053
  const startupTimeoutMs = options.startupTimeoutMs ?? 5000;
@@ -35005,31 +35102,40 @@ function createCocodClient(options = {}) {
35005
35102
  }
35006
35103
  async function startDaemon() {
35007
35104
  const env = { ...process.env, COCOD_SOCKET: socketPath };
35008
- const proc = spawnDaemon([executable, "daemon"], env);
35105
+ const proc = spawnDaemon([executable, "init"], env);
35009
35106
  const maxPolls = Math.ceil(startupTimeoutMs / pollIntervalMs);
35010
35107
  let exitCode = null;
35011
35108
  proc.exited.then((code) => {
35012
35109
  exitCode = code;
35013
35110
  });
35014
35111
  for (let i4 = 0;i4 < maxPolls; i4++) {
35015
- await delay(pollIntervalMs);
35016
- if (exitCode !== null) {
35017
- throw new Error(`cocod daemon exited early with code ${exitCode}`);
35112
+ await delay2(pollIntervalMs);
35113
+ if (exitCode !== null && exitCode !== 0) {
35114
+ throw new Error(`cocod init exited early with code ${exitCode}`);
35018
35115
  }
35019
35116
  if (await pingInternal()) {
35020
35117
  logger3.debug(`Connected to cocod daemon on ${socketPath}`);
35021
35118
  return;
35022
35119
  }
35023
35120
  }
35024
- throw new Error(`cocod daemon failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds`);
35121
+ throw new Error(`cocod failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds`);
35025
35122
  }
35026
35123
  async function ensureDaemonRunning() {
35027
35124
  if (await pingInternal()) {
35028
35125
  return;
35029
35126
  }
35030
35127
  if (!startPromise) {
35031
- logger3.debug(`Starting cocod daemon via ${executable}...`);
35032
- startPromise = startDaemon().finally(() => {
35128
+ startPromise = withCrossProcessLock(startupLockPath, async () => {
35129
+ if (await pingInternal()) {
35130
+ return;
35131
+ }
35132
+ logger3.debug(`Starting cocod daemon via ${executable} init...`);
35133
+ await startDaemon();
35134
+ }, {
35135
+ acquireTimeoutMs: startupTimeoutMs + 30000,
35136
+ staleAfterMs: startupTimeoutMs + 30000,
35137
+ log: (message) => logger3.debug(message)
35138
+ }).finally(() => {
35033
35139
  startPromise = null;
35034
35140
  });
35035
35141
  }
@@ -35257,6 +35363,9 @@ import { Readable } from "stream";
35257
35363
  // src/utils/daemon-client.ts
35258
35364
  import { existsSync as existsSync3 } from "fs";
35259
35365
 
35366
+ // src/start-daemon.ts
35367
+ var DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`;
35368
+
35260
35369
  // src/utils/nip98.ts
35261
35370
  init_esm2();
35262
35371
  var NIP98_KIND = 27235;
@@ -35363,8 +35472,8 @@ import { join as join4 } from "path";
35363
35472
 
35364
35473
  // src/integrations/opencode.ts
35365
35474
  import { existsSync as existsSync4, mkdirSync } from "fs";
35366
- import { readFile, writeFile } from "fs/promises";
35367
- import { dirname } from "path";
35475
+ import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
35476
+ import { dirname as dirname2 } from "path";
35368
35477
  var OPENCODE_SMALL_MODEL = "routstr/minimax-m2.5";
35369
35478
  async function installOpencodeIntegration(config, apiKey, integrationConfig) {
35370
35479
  const { name, configPath } = integrationConfig;
@@ -35375,7 +35484,7 @@ Installing routstr models in opencode.json...`);
35375
35484
  let opencodeConfig;
35376
35485
  try {
35377
35486
  if (existsSync4(configPath)) {
35378
- const content2 = await readFile(configPath, "utf-8");
35487
+ const content2 = await readFile2(configPath, "utf-8");
35379
35488
  opencodeConfig = JSON.parse(content2);
35380
35489
  } else {
35381
35490
  opencodeConfig = { provider: {} };
@@ -35387,7 +35496,7 @@ Installing routstr models in opencode.json...`);
35387
35496
  opencodeConfig.provider = {};
35388
35497
  }
35389
35498
  try {
35390
- mkdirSync(dirname(configPath), { recursive: true });
35499
+ mkdirSync(dirname2(configPath), { recursive: true });
35391
35500
  const data = await callDaemon("/models");
35392
35501
  const models = data.output?.models || [];
35393
35502
  if (models.length === 0) {
@@ -35409,7 +35518,7 @@ Installing routstr models in opencode.json...`);
35409
35518
  models: modelsObj
35410
35519
  };
35411
35520
  opencodeConfig.small_model = OPENCODE_SMALL_MODEL;
35412
- await writeFile(configPath, JSON.stringify(opencodeConfig, null, 2));
35521
+ await writeFile2(configPath, JSON.stringify(opencodeConfig, null, 2));
35413
35522
  logger3.log(`Added "routstr" provider with ${models.length} models to opencode.json`);
35414
35523
  } catch (error) {
35415
35524
  logger3.error("Failed to install models in opencode.json:", error);
@@ -35418,8 +35527,8 @@ Installing routstr models in opencode.json...`);
35418
35527
 
35419
35528
  // src/integrations/pi.ts
35420
35529
  import { existsSync as existsSync5, mkdirSync as mkdirSync2 } from "fs";
35421
- import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
35422
- import { dirname as dirname2 } from "path";
35530
+ import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
35531
+ import { dirname as dirname3 } from "path";
35423
35532
  async function installPiIntegration(config, apiKey, integrationConfig) {
35424
35533
  const { name, configPath } = integrationConfig;
35425
35534
  logger3.log(`
@@ -35429,7 +35538,7 @@ Installing routstr models in pi models.json...`);
35429
35538
  let piConfig = {};
35430
35539
  try {
35431
35540
  if (existsSync5(configPath)) {
35432
- const content2 = await readFile2(configPath, "utf-8");
35541
+ const content2 = await readFile3(configPath, "utf-8");
35433
35542
  piConfig = JSON.parse(content2);
35434
35543
  }
35435
35544
  } catch {
@@ -35439,7 +35548,7 @@ Installing routstr models in pi models.json...`);
35439
35548
  piConfig.providers = {};
35440
35549
  }
35441
35550
  try {
35442
- mkdirSync2(dirname2(configPath), { recursive: true });
35551
+ mkdirSync2(dirname3(configPath), { recursive: true });
35443
35552
  const data = await callDaemon("/models");
35444
35553
  const models = data.output?.models || [];
35445
35554
  if (models.length === 0) {
@@ -35455,7 +35564,7 @@ Installing routstr models in pi models.json...`);
35455
35564
  apiKey,
35456
35565
  models: providerModels
35457
35566
  };
35458
- await writeFile2(configPath, JSON.stringify(piConfig, null, 2));
35567
+ await writeFile3(configPath, JSON.stringify(piConfig, null, 2));
35459
35568
  logger3.log(`Added "routstr" provider with ${models.length} models to pi models.json`);
35460
35569
  } catch (error) {
35461
35570
  logger3.error("Failed to install models in pi models.json:", error);
@@ -35464,8 +35573,8 @@ Installing routstr models in pi models.json...`);
35464
35573
 
35465
35574
  // src/integrations/openclaw.ts
35466
35575
  import { existsSync as existsSync6, mkdirSync as mkdirSync3 } from "fs";
35467
- import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
35468
- import { dirname as dirname3 } from "path";
35576
+ import { readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
35577
+ import { dirname as dirname4 } from "path";
35469
35578
  var OPENCLAW_PROVIDER_ID = "routstr";
35470
35579
  var OPENCLAW_DEFAULT_PRIMARY_MODEL = "routstr/minimax-m2.5";
35471
35580
  var OPENCLAW_DEFAULT_FALLBACK_MODEL = "routstr/kimi-k2.5";
@@ -35478,7 +35587,7 @@ Installing routstr models in openclaw.json...`);
35478
35587
  let openclawConfig = {};
35479
35588
  try {
35480
35589
  if (existsSync6(configPath)) {
35481
- const content2 = await readFile3(configPath, "utf-8");
35590
+ const content2 = await readFile4(configPath, "utf-8");
35482
35591
  openclawConfig = JSON.parse(content2);
35483
35592
  }
35484
35593
  } catch {
@@ -35497,7 +35606,7 @@ Installing routstr models in openclaw.json...`);
35497
35606
  openclawConfig.agents.defaults = {};
35498
35607
  }
35499
35608
  try {
35500
- mkdirSync3(dirname3(configPath), { recursive: true });
35609
+ mkdirSync3(dirname4(configPath), { recursive: true });
35501
35610
  const data = await callDaemon("/models");
35502
35611
  const models = data.output?.models || [];
35503
35612
  if (models.length === 0) {
@@ -35529,7 +35638,7 @@ Installing routstr models in openclaw.json...`);
35529
35638
  fallbacks: [OPENCLAW_DEFAULT_FALLBACK_MODEL]
35530
35639
  };
35531
35640
  }
35532
- await writeFile3(configPath, JSON.stringify(openclawConfig, null, 2));
35641
+ await writeFile4(configPath, JSON.stringify(openclawConfig, null, 2));
35533
35642
  logger3.log(`Added "${OPENCLAW_PROVIDER_ID}" provider with ${models.length} models to openclaw.json`);
35534
35643
  } catch (error) {
35535
35644
  logger3.error("Failed to install models in openclaw.json:", error);
@@ -35538,8 +35647,8 @@ Installing routstr models in openclaw.json...`);
35538
35647
 
35539
35648
  // src/integrations/claudecode.ts
35540
35649
  import { existsSync as existsSync7, mkdirSync as mkdirSync4 } from "fs";
35541
- import { readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
35542
- import { dirname as dirname4 } from "path";
35650
+ import { readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
35651
+ import { dirname as dirname5 } from "path";
35543
35652
  async function installClaudeCodeIntegration(config, apiKey, integrationConfig) {
35544
35653
  const { name, configPath } = integrationConfig;
35545
35654
  logger3.log(`
@@ -35549,7 +35658,7 @@ Installing routstr configuration in ${configPath}...`);
35549
35658
  let settings = {};
35550
35659
  try {
35551
35660
  if (existsSync7(configPath)) {
35552
- const content2 = await readFile4(configPath, "utf-8");
35661
+ const content2 = await readFile5(configPath, "utf-8");
35553
35662
  settings = JSON.parse(content2);
35554
35663
  }
35555
35664
  } catch (error) {
@@ -35584,8 +35693,8 @@ Installing routstr configuration in ${configPath}...`);
35584
35693
  logger3.error("Failed to fetch models for Claude Code integration:", error);
35585
35694
  }
35586
35695
  try {
35587
- mkdirSync4(dirname4(configPath), { recursive: true });
35588
- await writeFile4(configPath, JSON.stringify(settings, null, 2));
35696
+ mkdirSync4(dirname5(configPath), { recursive: true });
35697
+ await writeFile5(configPath, JSON.stringify(settings, null, 2));
35589
35698
  logger3.log(`Successfully updated ${configPath} with routstr settings.`);
35590
35699
  } catch (error) {
35591
35700
  logger3.error(`Failed to write to ${configPath}:`, error);