Skip to content

Commit 6b93e66

Browse files
MajorTalclaude
andcommitted
Extract shared core/ module from MCP/CLI/OpenClaw
Move duplicated logic (config, wallet, wallet-auth, keystore, API client) into core/src/ so each interface is a thin presentation layer. - core/src/config.ts: path resolution and env vars - core/src/wallet.ts: read/write wallet.json with atomic writes - core/src/wallet-auth.ts: EIP-191 signing via @noble/curves - core/src/keystore.ts: unified object-based keystore with auto-migration from legacy array format and expires_at → lease_expires_at rename - core/src/client.ts: apiRequest() fetch wrapper MCP src/ files become thin re-exports from core. CLI config.mjs imports from core with process.exit wrappers. OpenClaw scripts become one-line re-exports from CLI modules (-1,771 lines). Shared paid-fetch.mjs deduplicates x402 payment setup across tier and image commands. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 850106b commit 6b93e66

58 files changed

Lines changed: 817 additions & 1771 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,50 +8,81 @@ run402-mcp is an MCP (Model Context Protocol) server that exposes Run402 develop
88

99
- **MCP server** (root `src/`) — the main package, published as `run402-mcp` on npm
1010
- **CLI** (`cli/`) — standalone CLI published as `run402` on npm, uses `@x402/fetch` for payments
11-
- **OpenClaw skill** (`openclaw/`) — skill for OpenClaw agents, calls the API via Node.js scripts
11+
- **OpenClaw skill** (`openclaw/`) — skill for OpenClaw agents, thin shims over CLI modules
12+
13+
All three share core logic via the `core/` module.
1214

1315
## Build & Test Commands
1416

1517
```bash
16-
npm run build # tsc → dist/
18+
npm run build:core # tsc -p core/tsconfig.json → core/dist/
19+
npm run build # build:core + tsc → dist/
1720
npm run start # node dist/index.js (stdio MCP transport)
1821
npm run test:skill # node --test --import tsx SKILL.test.ts (validates SKILL.md frontmatter/body)
1922
npm run test:sync # node --test --import tsx sync.test.ts (checks MCP/CLI/OpenClaw stay in sync)
20-
npm test # runs all tests (SKILL.test.ts + sync.test.ts + src/**/*.test.ts)
23+
npm test # runs all tests (SKILL.test.ts + sync.test.ts + core/src/**/*.test.ts + src/**/*.test.ts)
24+
npm run test:e2e # node --test cli-e2e.test.mjs (47 CLI end-to-end tests)
2125
```
2226

2327
Unit tests use Node's built-in `node:test` runner with `tsx` for TypeScript:
2428

2529
```bash
2630
# Run all unit tests
27-
node --test --import tsx src/**/*.test.ts
31+
node --test --import tsx core/src/**/*.test.ts src/**/*.test.ts
2832

2933
# Run a single test file
3034
node --test --import tsx src/tools/run-sql.test.ts
31-
node --test --import tsx src/client.test.ts
35+
node --test --import tsx core/src/keystore.test.ts
3236
```
3337

34-
Tests are excluded from the build (`tsconfig.json` excludes `src/**/*.test.ts`).
38+
Tests are excluded from the build (`tsconfig.json` and `core/tsconfig.json` both exclude `**/*.test.ts`).
3539

3640
### Sync Test (`sync.test.ts`)
3741

3842
`sync.test.ts` defines the canonical API surface in a `SURFACE` array and checks:
3943
- MCP tools in `src/index.ts` match the expected set (no missing, no extra)
4044
- CLI commands in `cli/lib/*.mjs` match the expected set
41-
- OpenClaw commands in `openclaw/scripts/*.mjs` match the expected set
45+
- OpenClaw commands in `openclaw/scripts/*.mjs` match the expected set (follows re-exports to CLI)
4246
- CLI and OpenClaw have identical command sets (parity)
4347
- If `~/dev/run402/site/llms.txt` exists: MCP Tools table lists all tools, all endpoints documented
4448

4549
When adding a new tool/command, add it to the `SURFACE` array in `sync.test.ts`.
4650

4751
## Architecture
4852

49-
### Core Modules (`src/`)
53+
### Shared Core (`core/src/`)
54+
55+
The `core/` module contains shared logic imported by all three interfaces:
56+
57+
- **`config.ts`** — Path resolution and env vars: `getApiBase()`, `getConfigDir()`, `getKeystorePath()`, `getWalletPath()`.
58+
- **`wallet.ts`**`readWallet()`, `saveWallet()` with atomic writes (temp-file + rename, mode 0600).
59+
- **`wallet-auth.ts`** — EIP-191 signing with `@noble/curves`. `getWalletAuthHeaders()` returns headers or null.
60+
- **`keystore.ts`** — Unified project credential store. Object schema: `{projects: {id: {anon_key, service_key, tier, lease_expires_at}}}`. Auto-migrates legacy array format and `expires_at``lease_expires_at`. Functions: `loadKeyStore()`, `saveKeyStore()`, `getProject()`, `saveProject()`, `removeProject()`.
61+
- **`client.ts`**`apiRequest()` fetch wrapper. Handles JSON/text responses, 402 payment detection.
62+
63+
Core functions return `null` or throw — they never call `process.exit()`. Each interface wraps with its own error behavior.
64+
65+
### MCP Server (`src/`)
66+
67+
Thin re-export layer over `core/dist/` plus MCP-specific wrappers:
68+
69+
- **`config.ts`**, **`client.ts`**, **`keystore.ts`**, **`wallet.ts`** — re-export from core
70+
- **`wallet-auth.ts`** — re-exports core's `getWalletAuthHeaders()` + adds `requireWalletAuth()` which returns MCP error shape
71+
- **`errors.ts`** — MCP-specific error formatting (`formatApiError`, `projectNotFound`)
72+
- **`index.ts`** — Entry point. Registers all tools via `McpServer`.
73+
- **`tools/*.ts`** — Each tool exports a Zod schema + async handler
74+
75+
### CLI (`cli/`)
76+
77+
- **`cli/lib/config.mjs`** — Imports from `core/dist/`, adds CLI wrappers (`walletAuthHeaders()` with process.exit, `findProject()` with process.exit). Re-exports core keystore functions.
78+
- **`cli/lib/paid-fetch.mjs`** — Shared `setupPaidFetch()` using viem + @x402/fetch for paid endpoints.
79+
- **`cli/lib/*.mjs`** — Each module exports `async run(sub, args)` with CLI output format.
80+
81+
### OpenClaw (`openclaw/`)
5082

51-
- **`index.ts`** — Entry point. Creates the `McpServer`, registers all tools with their Zod schemas and handlers, connects via `StdioServerTransport`.
52-
- **`client.ts`** — Single `apiRequest()` function wrapping `fetch()` against `RUN402_API_BASE`. Handles JSON/text responses and identifies 402 (payment required) responses with `is402` flag.
53-
- **`config.ts`** — Reads `RUN402_API_BASE` (default `https://api.run402.com`) and `RUN402_CONFIG_DIR` (default `~/.config/run402`) from env.
54-
- **`keystore.ts`** — Atomic file-based credential store at `~/.config/run402/projects.json` (mode 0600). Uses temp-file + rename for safe writes.
83+
- **`openclaw/scripts/config.mjs`** — Re-exports from `cli/lib/config.mjs`
84+
- **`openclaw/scripts/*.mjs`** — Thin shims: `export { run } from "../../cli/lib/<name>.mjs"`
85+
- **`openclaw/scripts/init.mjs`** — Calls CLI's `run()` at top level (executable script)
5586

5687
### Tool Pattern
5788

cli-e2e.test.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -447,9 +447,9 @@ describe("CLI e2e happy path", () => {
447447
await run("provision", ["--tier", "prototype"]);
448448
captureStop();
449449
assert.ok(captured().includes("prj_test123"), "should return project_id");
450-
// Verify project saved locally
451-
const projects = JSON.parse(readFileSync(join(tempDir, "projects.json"), "utf-8"));
452-
assert.ok(projects.some(p => p.project_id === "prj_test123"), "project should be saved locally");
450+
// Verify project saved locally (unified object-based keystore format)
451+
const store = JSON.parse(readFileSync(join(tempDir, "projects.json"), "utf-8"));
452+
assert.ok(store.projects && store.projects["prj_test123"], "project should be saved locally");
453453
});
454454

455455
it("projects list", async () => {

cli/lib/apps.mjs

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { findProject, loadProjects, saveProjects, API, PROJECTS_FILE, walletAuthHeaders } from "./config.mjs";
2-
import { mkdirSync, writeFileSync } from "fs";
1+
import { findProject, API, walletAuthHeaders, saveProject } from "./config.mjs";
32

43
const HELP = `run402 apps — Browse and manage the app marketplace
54
@@ -63,15 +62,12 @@ async function fork(versionId, name, args) {
6362

6463
// Save project credentials locally
6564
if (data.project_id) {
66-
const projects = loadProjects();
67-
projects.push({
68-
project_id: data.project_id, anon_key: data.anon_key, service_key: data.service_key,
65+
saveProject(data.project_id, {
66+
anon_key: data.anon_key, service_key: data.service_key,
6967
tier: data.tier, lease_expires_at: data.lease_expires_at,
70-
site_url: data.site_url || data.subdomain_url, deployed_at: new Date().toISOString(),
68+
site_url: data.site_url || data.subdomain_url,
69+
deployed_at: new Date().toISOString(),
7170
});
72-
const dir = PROJECTS_FILE.replace(/\/[^/]+$/, "");
73-
mkdirSync(dir, { recursive: true });
74-
writeFileSync(PROJECTS_FILE, JSON.stringify(projects, null, 2), { mode: 0o600 });
7571
}
7672
console.log(JSON.stringify(data, null, 2));
7773
}

cli/lib/config.mjs

Lines changed: 19 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,37 @@
11
/**
2-
* Run402 config loader — reads local project and wallet state.
3-
* Kept in a separate module so credential reads stay isolated.
2+
* Run402 config loader — thin wrapper over core/ shared modules.
3+
* Adds CLI-specific behavior: process.exit() on errors.
44
*/
55

6-
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, renameSync } from "fs";
7-
import { join, dirname } from "path";
8-
import { homedir } from "os";
9-
import { randomBytes } from "crypto";
6+
import { getApiBase, getConfigDir, getKeystorePath, getWalletPath } from "../../core/dist/config.js";
7+
import { readWallet as coreReadWallet, saveWallet as coreSaveWallet } from "../../core/dist/wallet.js";
8+
import { getWalletAuthHeaders } from "../../core/dist/wallet-auth.js";
9+
import { loadKeyStore, getProject, saveProject, removeProject, saveKeyStore } from "../../core/dist/keystore.js";
1010

11-
export const CONFIG_DIR = process.env.RUN402_CONFIG_DIR || join(homedir(), ".config", "run402");
12-
export const WALLET_FILE = join(CONFIG_DIR, "wallet.json");
13-
export const PROJECTS_FILE = join(CONFIG_DIR, "projects.json");
14-
export const API = process.env.RUN402_API_BASE || "https://api.run402.com";
11+
export const CONFIG_DIR = getConfigDir();
12+
export const WALLET_FILE = getWalletPath();
13+
export const PROJECTS_FILE = getKeystorePath();
14+
export const API = getApiBase();
1515

1616
export function readWallet() {
17-
if (!existsSync(WALLET_FILE)) return null;
18-
return JSON.parse(readFileSync(WALLET_FILE, "utf-8"));
17+
return coreReadWallet();
1918
}
2019

2120
export function saveWallet(data) {
22-
mkdirSync(CONFIG_DIR, { recursive: true });
23-
const tmp = join(CONFIG_DIR, `.wallet.${randomBytes(4).toString("hex")}.tmp`);
24-
writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
25-
renameSync(tmp, WALLET_FILE);
26-
chmodSync(WALLET_FILE, 0o600);
27-
}
28-
29-
export function loadProjects() {
30-
if (!existsSync(PROJECTS_FILE)) return [];
31-
return JSON.parse(readFileSync(PROJECTS_FILE, "utf-8"));
32-
}
33-
34-
export function saveProjects(projects) {
35-
mkdirSync(CONFIG_DIR, { recursive: true });
36-
writeFileSync(PROJECTS_FILE, JSON.stringify(projects, null, 2), { mode: 0o600 });
21+
coreSaveWallet(data);
3722
}
3823

3924
export async function walletAuthHeaders() {
40-
const w = readWallet();
41-
if (!w) { console.error(JSON.stringify({ status: "error", message: "No wallet found. Run: run402 wallet create" })); process.exit(1); }
42-
const { privateKeyToAccount } = await import("viem/accounts");
43-
const account = privateKeyToAccount(w.privateKey);
44-
const timestamp = Math.floor(Date.now() / 1000).toString();
45-
const signature = await account.signMessage({ message: `run402:${timestamp}` });
46-
return { "X-Run402-Wallet": account.address, "X-Run402-Signature": signature, "X-Run402-Timestamp": timestamp };
25+
const headers = getWalletAuthHeaders();
26+
if (!headers) { console.error(JSON.stringify({ status: "error", message: "No wallet found. Run: run402 wallet create" })); process.exit(1); }
27+
return headers;
4728
}
4829

4930
export function findProject(id) {
50-
const p = loadProjects().find(p => p.project_id === id);
31+
const p = getProject(id);
5132
if (!p) { console.error(`Project ${id} not found in local registry.`); process.exit(1); }
5233
return p;
5334
}
35+
36+
// Re-export core keystore functions for direct use
37+
export { loadKeyStore, saveProject, removeProject, saveKeyStore };

cli/lib/deploy.mjs

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { readFileSync, mkdirSync, writeFileSync } from "fs";
2-
import { loadProjects, API, PROJECTS_FILE, walletAuthHeaders } from "./config.mjs";
1+
import { readFileSync } from "fs";
2+
import { API, walletAuthHeaders, saveProject } from "./config.mjs";
33

44
const HELP = `run402 deploy — Deploy a full-stack app or static site on Run402
55
@@ -39,14 +39,6 @@ async function readStdin() {
3939
return Buffer.concat(chunks).toString("utf-8");
4040
}
4141

42-
function saveProject(project) {
43-
const projects = loadProjects();
44-
projects.push({ project_id: project.project_id, anon_key: project.anon_key, service_key: project.service_key, tier: project.tier, lease_expires_at: project.lease_expires_at, site_url: project.site_url || project.subdomain_url, deployed_at: new Date().toISOString() });
45-
const dir = PROJECTS_FILE.replace(/\/[^/]+$/, "");
46-
mkdirSync(dir, { recursive: true });
47-
writeFileSync(PROJECTS_FILE, JSON.stringify(projects, null, 2), { mode: 0o600 });
48-
}
49-
5042
export async function run(args) {
5143
const opts = { manifest: null };
5244
for (let i = 0; i < args.length; i++) {
@@ -60,6 +52,13 @@ export async function run(args) {
6052
const res = await fetch(`${API}/deploy/v1`, { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders }, body: JSON.stringify(manifest) });
6153
const result = await res.json();
6254
if (!res.ok) { console.error(JSON.stringify({ status: "error", http: res.status, ...result })); process.exit(1); }
63-
saveProject(result);
55+
if (result.project_id) {
56+
saveProject(result.project_id, {
57+
anon_key: result.anon_key, service_key: result.service_key,
58+
tier: result.tier, lease_expires_at: result.lease_expires_at,
59+
site_url: result.site_url || result.subdomain_url,
60+
deployed_at: new Date().toISOString(),
61+
});
62+
}
6463
console.log(JSON.stringify(result, null, 2));
6564
}

cli/lib/image.mjs

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { writeFileSync, existsSync } from "fs";
2-
import { readWallet, API, WALLET_FILE } from "./config.mjs";
1+
import { writeFileSync } from "fs";
2+
import { API, WALLET_FILE } from "./config.mjs";
3+
import { setupPaidFetch } from "./paid-fetch.mjs";
34

45
const HELP = `run402 image — Generate AI images via x402 micropayments
56
@@ -49,22 +50,8 @@ export async function run(sub, args) {
4950
}
5051

5152
if (!opts.prompt) { console.error(JSON.stringify({ status: "error", message: "Prompt required. Usage: run402 image generate \"your prompt\"" })); process.exit(1); }
52-
if (!existsSync(WALLET_FILE)) { console.error(JSON.stringify({ status: "error", message: "No wallet found. Run: run402 wallet create && run402 wallet fund" })); process.exit(1); }
5353

54-
const wallet = readWallet();
55-
const { privateKeyToAccount } = await import("viem/accounts");
56-
const { createPublicClient, http } = await import("viem");
57-
const { baseSepolia } = await import("viem/chains");
58-
const { x402Client, wrapFetchWithPayment } = await import("@x402/fetch");
59-
const { ExactEvmScheme } = await import("@x402/evm/exact/client");
60-
const { toClientEvmSigner } = await import("@x402/evm");
61-
62-
const account = privateKeyToAccount(wallet.privateKey);
63-
const publicClient = createPublicClient({ chain: baseSepolia, transport: http() });
64-
const signer = toClientEvmSigner(account, publicClient);
65-
const client = new x402Client();
66-
client.register("eip155:84532", new ExactEvmScheme(signer));
67-
const fetchPaid = wrapFetchWithPayment(fetch, client);
54+
const fetchPaid = await setupPaidFetch();
6855

6956
const res = await fetchPaid(`${API}/generate-image/v1`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: opts.prompt, aspect: opts.aspect }) });
7057
const data = await res.json();

cli/lib/init.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readWallet, saveWallet, loadProjects, CONFIG_DIR, WALLET_FILE, API } from "./config.mjs";
1+
import { readWallet, saveWallet, loadKeyStore, CONFIG_DIR, WALLET_FILE, API } from "./config.mjs";
22
import { mkdirSync } from "fs";
33

44
const USDC_ABI = [{ name: "balanceOf", type: "function", stateMutability: "view", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }] }];
@@ -91,8 +91,8 @@ export async function run() {
9191
}
9292

9393
// 5. Projects
94-
const projects = loadProjects();
95-
line("Projects", `${projects.length} active`);
94+
const store = loadKeyStore();
95+
line("Projects", `${Object.keys(store.projects).length} active`);
9696

9797
// 6. Next step
9898
console.log();

cli/lib/paid-fetch.mjs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Shared x402 payment wrapper for CLI commands that need paid fetch.
3+
* Uses viem for wallet signing + @x402/fetch for payment wrapping.
4+
*/
5+
6+
import { readWallet, WALLET_FILE } from "./config.mjs";
7+
import { existsSync } from "fs";
8+
9+
export async function setupPaidFetch() {
10+
if (!existsSync(WALLET_FILE)) {
11+
console.error(JSON.stringify({ status: "error", message: "No wallet found. Run: run402 wallet create && run402 wallet fund" }));
12+
process.exit(1);
13+
}
14+
const wallet = readWallet();
15+
const { privateKeyToAccount } = await import("viem/accounts");
16+
const { createPublicClient, http } = await import("viem");
17+
const { baseSepolia } = await import("viem/chains");
18+
const { x402Client, wrapFetchWithPayment } = await import("@x402/fetch");
19+
const { ExactEvmScheme } = await import("@x402/evm/exact/client");
20+
const { toClientEvmSigner } = await import("@x402/evm");
21+
const account = privateKeyToAccount(wallet.privateKey);
22+
const publicClient = createPublicClient({ chain: baseSepolia, transport: http() });
23+
const signer = toClientEvmSigner(account, publicClient);
24+
const client = new x402Client();
25+
client.register("eip155:84532", new ExactEvmScheme(signer));
26+
return wrapFetchWithPayment(fetch, client);
27+
}

cli/lib/projects.mjs

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { findProject, loadProjects, saveProjects, API, PROJECTS_FILE, walletAuthHeaders } from "./config.mjs";
2-
import { mkdirSync, writeFileSync } from "fs";
1+
import { findProject, loadKeyStore, saveProject, removeProject, API, walletAuthHeaders } from "./config.mjs";
32

43
const HELP = `run402 projects — Manage your deployed Run402 projects
54
@@ -61,14 +60,11 @@ async function provision(args) {
6160
if (!res.ok) { console.error(JSON.stringify({ status: "error", http: res.status, ...data })); process.exit(1); }
6261
// Save project credentials locally
6362
if (data.project_id) {
64-
const projects = loadProjects();
65-
projects.push({
66-
project_id: data.project_id, anon_key: data.anon_key, service_key: data.service_key,
67-
tier: data.tier, lease_expires_at: data.lease_expires_at, deployed_at: new Date().toISOString(),
63+
saveProject(data.project_id, {
64+
anon_key: data.anon_key, service_key: data.service_key,
65+
tier: data.tier, lease_expires_at: data.lease_expires_at,
66+
deployed_at: new Date().toISOString(),
6867
});
69-
const dir = PROJECTS_FILE.replace(/\/[^/]+$/, "");
70-
mkdirSync(dir, { recursive: true });
71-
writeFileSync(PROJECTS_FILE, JSON.stringify(projects, null, 2), { mode: 0o600 });
7268
}
7369
console.log(JSON.stringify(data, null, 2));
7470
}
@@ -87,9 +83,10 @@ async function rls(projectId, template, tablesJson) {
8783
}
8884

8985
async function list() {
90-
const projects = loadProjects();
91-
if (projects.length === 0) { console.log(JSON.stringify({ status: "ok", projects: [], message: "No projects yet." })); return; }
92-
console.log(JSON.stringify(projects.map(p => ({ project_id: p.project_id, tier: p.tier, site_url: p.site_url, lease_expires_at: p.lease_expires_at, deployed_at: p.deployed_at })), null, 2));
86+
const store = loadKeyStore();
87+
const entries = Object.entries(store.projects);
88+
if (entries.length === 0) { console.log(JSON.stringify({ status: "ok", projects: [], message: "No projects yet." })); return; }
89+
console.log(JSON.stringify(entries.map(([id, p]) => ({ project_id: id, tier: p.tier, site_url: p.site_url, lease_expires_at: p.lease_expires_at, deployed_at: p.deployed_at })), null, 2));
9390
}
9491

9592
async function sqlCmd(projectId, query) {
@@ -124,7 +121,7 @@ async function deleteProject(projectId) {
124121
const p = findProject(projectId);
125122
const res = await fetch(`${API}/projects/v1/${projectId}`, { method: "DELETE", headers: { "Authorization": `Bearer ${p.service_key}` } });
126123
if (res.status === 204 || res.ok) {
127-
saveProjects(loadProjects().filter(pr => pr.project_id !== projectId));
124+
removeProject(projectId);
128125
console.log(JSON.stringify({ status: "ok", message: `Project ${projectId} deleted.` }));
129126
} else {
130127
const data = await res.json().catch(() => ({}));

0 commit comments

Comments
 (0)