spendctl 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +83 -0
- package/README.md +220 -2
- package/THIRD_PARTY_NOTICES +3987 -0
- package/bin/spendctl.js +10 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +653 -0
- package/dist/index.js.map +1 -0
- package/dist/state.d.ts +37 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +112 -0
- package/dist/state.js.map +1 -0
- package/dist/templates.d.ts +4 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +63 -0
- package/dist/templates.js.map +1 -0
- package/dist/ui.d.ts +15 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +55 -0
- package/dist/ui.js.map +1 -0
- package/package.json +43 -3
package/bin/spendctl.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// node:sqlite is used for the ledger; hide its one-line ExperimentalWarning on Node 22.
|
|
3
|
+
const emit = process.emitWarning.bind(process);
|
|
4
|
+
process.emitWarning = (warning, ...rest) => {
|
|
5
|
+
const text = typeof warning === "string" ? warning : (warning?.message ?? "");
|
|
6
|
+
if (text.includes("SQLite is an experimental feature")) return;
|
|
7
|
+
return emit(warning, ...rest);
|
|
8
|
+
};
|
|
9
|
+
const { main } = await import("../dist/index.js");
|
|
10
|
+
process.exitCode = await main(process.argv.slice(2));
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA0CA,eAAO,MAAM,OAAO,UAAU,CAAC;AAwC/B,KAAK,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;AAmmBrE,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA2B1D;AAED,YAAY,EAAE,KAAK,EAAE,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { appendFileSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { relative, resolve } from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { AdminClient, readRefusal, wrapFetch } from "@spendctl/client";
|
|
6
|
+
import { ConfigError, checkPlan, formatUsd, isSandbox, Ledger, LICENSE_GRACE_MS, LicenseError, loadConfig, newToken, PlanError, PRICING_URL, parseLicenseKey, planLine, planUsage, TIER_LABELS, } from "@spendctl/core";
|
|
7
|
+
import { createSigner, listen, requestTestnetUsdc, Sidecar } from "@spendctl/proxy";
|
|
8
|
+
import { ensureAdminToken, loadDotenv, loadPlan, paths, readAdminToken, readAgentTokens, removeLicenseKey, sidecarUrl, tryLoadConfig, writeAgentTokens, writeLicenseKey, } from "./state.js";
|
|
9
|
+
import { ENV_EXAMPLE, policyTemplate } from "./templates.js";
|
|
10
|
+
import { c, short, statusColor, table } from "./ui.js";
|
|
11
|
+
export const VERSION = "0.2.0";
|
|
12
|
+
const HELP = `${c.bold("spendctl")} ${VERSION} — budgets, approvals and receipts for AI agents that pay (x402, MPP)
|
|
13
|
+
|
|
14
|
+
${c.bold("Usage")}
|
|
15
|
+
spendctl <command> [options]
|
|
16
|
+
|
|
17
|
+
${c.bold("Setup")}
|
|
18
|
+
init [--signer mock|cdp|private-key] [--force] Create spendctl.yaml and .spendctl/
|
|
19
|
+
start [--dry-run] [--port N] Run the sidecar (agents send requests here)
|
|
20
|
+
mock Run a mock facilitator + two paid APIs (ports 4020-4022)
|
|
21
|
+
|
|
22
|
+
${c.bold("Agents")}
|
|
23
|
+
fetch <url> [--agent A] [-X METHOD] [-d BODY] [-H 'K: V'] [--dry-run]
|
|
24
|
+
Make a request through the sidecar as an agent
|
|
25
|
+
run --agent A -- <command...> Run a command with HTTP_PROXY pointing at the sidecar
|
|
26
|
+
mcp Serve the MCP tools pay / check_budget / list_receipts (stdio)
|
|
27
|
+
token <agent> Create a per-agent token (for agentAuth: token)
|
|
28
|
+
|
|
29
|
+
${c.bold("Humans")}
|
|
30
|
+
pending List payments waiting for approval
|
|
31
|
+
approve <id> | --latest | --all [--note TEXT] Approve
|
|
32
|
+
deny <id> | --latest [--note TEXT] Deny
|
|
33
|
+
budget [--agent A] Show budgets and what is left
|
|
34
|
+
ledger [--agent A] [--limit N] Show recent payment decisions
|
|
35
|
+
export [--format csv|json] [--out FILE] [--agent A] [--since ISO]
|
|
36
|
+
Export receipts for expense reports
|
|
37
|
+
trace <attempt-id> Show an agent→agent payment chain
|
|
38
|
+
resolve <attempt-id> --settled|--failed [--tx HASH]
|
|
39
|
+
Reconcile an attempt whose outcome was unknown
|
|
40
|
+
dashboard [--open] Print (or open) the local dashboard URL
|
|
41
|
+
wallet [address|fund] Show the signer address / request testnet USDC (CDP)
|
|
42
|
+
license [set <key> | remove] Show the plan and this month's usage / store or remove a licence key
|
|
43
|
+
|
|
44
|
+
${c.bold("Global options")}
|
|
45
|
+
-c, --config FILE Policy file (default ./spendctl.yaml or $SPENDCTL_CONFIG)
|
|
46
|
+
-h, --help Show help
|
|
47
|
+
-v, --version Show version
|
|
48
|
+
`;
|
|
49
|
+
function parse(argv, options = {}) {
|
|
50
|
+
const result = parseArgs({
|
|
51
|
+
args: argv,
|
|
52
|
+
allowPositionals: true,
|
|
53
|
+
strict: true,
|
|
54
|
+
options: {
|
|
55
|
+
config: { type: "string", short: "c" },
|
|
56
|
+
help: { type: "boolean", short: "h" },
|
|
57
|
+
...options,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
return { values: result.values, positionals: result.positionals };
|
|
61
|
+
}
|
|
62
|
+
function out(text = "") {
|
|
63
|
+
process.stdout.write(`${text}\n`);
|
|
64
|
+
}
|
|
65
|
+
function err(text) {
|
|
66
|
+
process.stderr.write(`${text}\n`);
|
|
67
|
+
}
|
|
68
|
+
function admin(p) {
|
|
69
|
+
return new AdminClient({ url: sidecarUrl(p), token: readAdminToken(p) });
|
|
70
|
+
}
|
|
71
|
+
/** Inside the 30-day grace window the paid limits still apply; say so once a day (docs/PLANS.md). */
|
|
72
|
+
function expiryWarning(plan, now = Date.now()) {
|
|
73
|
+
if (plan.expiresAt === undefined || plan.expiresAt - now > LICENSE_GRACE_MS)
|
|
74
|
+
return undefined;
|
|
75
|
+
const days = Math.max(0, Math.ceil((plan.expiresAt - now) / 86_400_000));
|
|
76
|
+
const date = new Date(plan.expiresAt).toISOString().slice(0, 10);
|
|
77
|
+
return `licence key expires in ${days} day${days === 1 ? "" : "s"} (${date}); after that the sidecar runs with the Free limits. Renew: ${PRICING_URL}`;
|
|
78
|
+
}
|
|
79
|
+
/** This month's usage: from the running sidecar when there is one, else straight from the ledger. */
|
|
80
|
+
async function usageFor(p, plan) {
|
|
81
|
+
try {
|
|
82
|
+
const live = (await admin(p).summary()).plan;
|
|
83
|
+
if (live)
|
|
84
|
+
return live;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// no sidecar running (or no admin token yet): read the ledger directly
|
|
88
|
+
}
|
|
89
|
+
const config = tryLoadConfig(p);
|
|
90
|
+
const ledger = await Ledger.open(resolve(p.root, config?.ledger.path ?? ".spendctl/ledger.db"));
|
|
91
|
+
try {
|
|
92
|
+
return planUsage(plan, ledger, { sandbox: config ? isSandbox(config) : false });
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
ledger.close();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function eventLine(event) {
|
|
99
|
+
const a = event.attempt;
|
|
100
|
+
const time = new Date().toTimeString().slice(0, 8);
|
|
101
|
+
if (event.type === "approval" && event.approval) {
|
|
102
|
+
const amount = event.approval.usdMicros !== undefined ? formatUsd(BigInt(event.approval.usdMicros)) : "";
|
|
103
|
+
return `${c.yellow("⏸")} ${c.gray(time)} ${event.approval.agent} ${a?.method ?? ""} ${event.approval.host}${event.approval.path} ${c.bold(amount)} ${c.yellow("needs approval")} ${c.bold(event.approval.id)}\n ${c.dim(event.approval.reasons.map((r) => r.message).join(" "))}\n ${c.dim("→ run:")} spendctl approve ${event.approval.id}`;
|
|
104
|
+
}
|
|
105
|
+
if (!a) {
|
|
106
|
+
if (event.type === "plan")
|
|
107
|
+
return `${c.yellow("!")} ${c.gray(time)} ${event.message}`;
|
|
108
|
+
return event.type === "error" ? `${c.red("!")} ${c.gray(time)} ${event.message}` : undefined;
|
|
109
|
+
}
|
|
110
|
+
const amount = a.usdMicros !== undefined ? formatUsd(a.usdMicros) : "-";
|
|
111
|
+
const where = `${a.method} ${a.host}${a.path}`;
|
|
112
|
+
if (event.type === "settled") {
|
|
113
|
+
return `${c.green("✔")} ${c.gray(time)} ${a.agent} ${where} ${c.bold(amount)} ${statusColor("settled")} ${c.gray(short(a.txHash, 14))}`;
|
|
114
|
+
}
|
|
115
|
+
if (event.type === "failed") {
|
|
116
|
+
return `${c.red("✖")} ${c.gray(time)} ${a.agent} ${where} ${amount} ${statusColor(a.status)} ${c.dim(a.error ?? "")}`;
|
|
117
|
+
}
|
|
118
|
+
if (event.type === "decision" && (a.status === "denied" || a.status === "dry_run")) {
|
|
119
|
+
const icon = a.status === "dry_run" ? c.yellow("○") : c.red("✖");
|
|
120
|
+
return `${icon} ${c.gray(time)} ${a.agent} ${where} ${amount} ${statusColor(a.status)} ${c.dim(a.reasons[0]?.message ?? "")}`;
|
|
121
|
+
}
|
|
122
|
+
if (event.type === "decision" && a.status === "expired") {
|
|
123
|
+
// A held request that ended without a payment: the requester left, or holdMax passed.
|
|
124
|
+
return `${c.yellow("⏸")} ${c.gray(time)} ${a.agent} ${where} ${amount} ${statusColor("expired")} ${c.dim(event.message.replace(`${a.id} `, ""))}`;
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
// ---------------------------------------------------------------- commands
|
|
129
|
+
async function cmdInit(argv) {
|
|
130
|
+
const { values } = parse(argv, { signer: { type: "string" }, force: { type: "boolean" } });
|
|
131
|
+
const p = paths(values.config);
|
|
132
|
+
const signer = (values.signer ?? "mock");
|
|
133
|
+
if (!["mock", "cdp", "private-key"].includes(signer)) {
|
|
134
|
+
err(`Unknown signer "${signer}" (use mock, cdp or private-key)`);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
if (existsSync(p.config) && !values.force) {
|
|
138
|
+
err(`${relative(process.cwd(), p.config) || p.config} already exists (use --force to overwrite).`);
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
writeFileSync(p.config, policyTemplate(signer));
|
|
142
|
+
ensureAdminToken(p);
|
|
143
|
+
const gitignore = resolve(p.root, ".gitignore");
|
|
144
|
+
const lines = existsSync(gitignore) ? readFileSync(gitignore, "utf8") : "";
|
|
145
|
+
const missing = [".spendctl/", ".env"].filter((entry) => !lines.split(/\r?\n/).includes(entry));
|
|
146
|
+
if (missing.length)
|
|
147
|
+
appendFileSync(gitignore, `${lines && !lines.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`);
|
|
148
|
+
if (signer !== "mock" && !existsSync(resolve(p.root, ".env.example")))
|
|
149
|
+
writeFileSync(resolve(p.root, ".env.example"), ENV_EXAMPLE);
|
|
150
|
+
out(`${c.green("✔")} wrote ${relative(process.cwd(), p.config) || "spendctl.yaml"} (signer: ${signer})`);
|
|
151
|
+
out(`${c.green("✔")} created .spendctl/ (admin token, ledger) — git-ignored`);
|
|
152
|
+
out("");
|
|
153
|
+
out(c.bold("Next:"));
|
|
154
|
+
if (signer === "mock") {
|
|
155
|
+
out(` 1. ${c.cyan("npx spendctl mock")} # terminal 1: mock facilitator + paid APIs`);
|
|
156
|
+
out(` 2. ${c.cyan("npx spendctl start")} # terminal 2: the sidecar`);
|
|
157
|
+
out(` 3. ${c.cyan("npx spendctl fetch http://localhost:4021/weather --agent researcher")}`);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
out(` 1. Fill in .env (see .env.example)`);
|
|
161
|
+
out(` 2. ${c.cyan("npx spendctl wallet fund")} # testnet USDC from the CDP faucet`);
|
|
162
|
+
out(` 3. ${c.cyan("npx spendctl start")}`);
|
|
163
|
+
}
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
async function cmdStart(argv) {
|
|
167
|
+
const { values } = parse(argv, {
|
|
168
|
+
"dry-run": { type: "boolean" },
|
|
169
|
+
port: { type: "string" },
|
|
170
|
+
host: { type: "string" },
|
|
171
|
+
});
|
|
172
|
+
const p = paths(values.config);
|
|
173
|
+
loadDotenv(p.root);
|
|
174
|
+
const config = loadConfig(p.config);
|
|
175
|
+
if (values.port)
|
|
176
|
+
config.server.port = Number(values.port);
|
|
177
|
+
if (values.host)
|
|
178
|
+
config.server.host = String(values.host);
|
|
179
|
+
const license = loadPlan(p);
|
|
180
|
+
if (license.warning)
|
|
181
|
+
err(`${c.yellow("!")} ${license.warning}`);
|
|
182
|
+
try {
|
|
183
|
+
checkPlan(config, license.plan);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (!(error instanceof PlanError))
|
|
187
|
+
throw error;
|
|
188
|
+
err(c.red(error.message));
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
const adminToken = ensureAdminToken(p);
|
|
192
|
+
const signer = await createSigner(config.signer);
|
|
193
|
+
const ledgerPath = resolve(p.root, config.ledger.path);
|
|
194
|
+
const ledger = await Ledger.open(ledgerPath);
|
|
195
|
+
const sidecar = new Sidecar({
|
|
196
|
+
config,
|
|
197
|
+
ledger,
|
|
198
|
+
evmSigner: signer.signer,
|
|
199
|
+
adminToken,
|
|
200
|
+
agentTokens: readAgentTokens(p),
|
|
201
|
+
dryRun: Boolean(values["dry-run"]),
|
|
202
|
+
plan: license.plan,
|
|
203
|
+
onEvent: (event) => {
|
|
204
|
+
const line = eventLine(event);
|
|
205
|
+
if (line)
|
|
206
|
+
out(line);
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
const running = await listen(sidecar);
|
|
210
|
+
writeFileSync(p.sidecar, `${JSON.stringify({ url: running.url, pid: process.pid, startedAt: new Date().toISOString() })}\n`);
|
|
211
|
+
const agents = Object.keys(config.agents);
|
|
212
|
+
out(`${c.bold("spendctl")} ${VERSION} listening on ${c.cyan(running.url)}${values["dry-run"] ? c.yellow(" [DRY RUN — nothing will be paid]") : ""}`);
|
|
213
|
+
out(`${c.dim("signer ")} ${signer.kind} · ${signer.address} ${c.dim(`(${signer.description})`)}`);
|
|
214
|
+
out(`${c.dim("policy ")} ${relative(process.cwd(), p.config) || p.config} · ${agents.length} agent(s): ${agents.join(", ")}`);
|
|
215
|
+
out(`${c.dim("ledger ")} ${relative(process.cwd(), ledgerPath) || ledgerPath}`);
|
|
216
|
+
out(`${c.dim("plan ")} ${planLine(sidecar.usage())}`);
|
|
217
|
+
out(`${c.dim("agents ")} send requests to ${running.url}/<absolute-url> with header X-Spendctl-Agent`);
|
|
218
|
+
const warnExpiry = () => {
|
|
219
|
+
const warning = expiryWarning(license.plan);
|
|
220
|
+
if (warning)
|
|
221
|
+
err(`${c.yellow("!")} ${warning}`);
|
|
222
|
+
};
|
|
223
|
+
warnExpiry();
|
|
224
|
+
setInterval(warnExpiry, 86_400_000).unref();
|
|
225
|
+
const stop = async () => {
|
|
226
|
+
await running.close();
|
|
227
|
+
ledger.close();
|
|
228
|
+
rmSync(p.sidecar, { force: true });
|
|
229
|
+
process.exit(0);
|
|
230
|
+
};
|
|
231
|
+
process.on("SIGINT", stop);
|
|
232
|
+
process.on("SIGTERM", stop);
|
|
233
|
+
setInterval(() => sidecar.broker.expireStale(), 5_000).unref();
|
|
234
|
+
await new Promise(() => undefined);
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
async function cmdMock(argv) {
|
|
238
|
+
const { values } = parse(argv, { base: { type: "string" } });
|
|
239
|
+
const { startDemoServers } = await import("@spendctl/mock");
|
|
240
|
+
const base = Number(values.base ?? 4020);
|
|
241
|
+
const servers = await startDemoServers({ facilitator: base, weather: base + 1, search: base + 2 });
|
|
242
|
+
out(`${c.bold("spendctl mock")} — nothing here touches a real chain`);
|
|
243
|
+
out(`${c.dim("facilitator")} ${servers.facilitator.url} (verifies EIP-3009 signatures, fake settlement)`);
|
|
244
|
+
out(`${c.dim("weather ")} http://localhost:${servers.weather.port}/weather?city=Tokyo $0.01 x402 v2`);
|
|
245
|
+
out(`${c.dim("search ")} http://localhost:${servers.search.port}/search?q=x402 $0.02 x402 v1`);
|
|
246
|
+
const stop = async () => {
|
|
247
|
+
await servers.close();
|
|
248
|
+
process.exit(0);
|
|
249
|
+
};
|
|
250
|
+
process.on("SIGINT", stop);
|
|
251
|
+
process.on("SIGTERM", stop);
|
|
252
|
+
await new Promise(() => undefined);
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
255
|
+
async function cmdFetch(argv) {
|
|
256
|
+
const { values, positionals } = parse(argv, {
|
|
257
|
+
agent: { type: "string", short: "a" },
|
|
258
|
+
method: { type: "string", short: "X" },
|
|
259
|
+
data: { type: "string", short: "d" },
|
|
260
|
+
header: { type: "string", short: "H", multiple: true },
|
|
261
|
+
"dry-run": { type: "boolean" },
|
|
262
|
+
quiet: { type: "boolean", short: "q" },
|
|
263
|
+
});
|
|
264
|
+
const url = positionals[0];
|
|
265
|
+
if (!url) {
|
|
266
|
+
err("Usage: spendctl fetch <url> [--agent A]");
|
|
267
|
+
return 1;
|
|
268
|
+
}
|
|
269
|
+
const p = paths(values.config);
|
|
270
|
+
const config = tryLoadConfig(p);
|
|
271
|
+
const agent = values.agent ?? process.env.SPENDCTL_AGENT ?? config?.defaultAgent;
|
|
272
|
+
const headers = new Headers();
|
|
273
|
+
for (const header of values.header ?? []) {
|
|
274
|
+
const i = header.indexOf(":");
|
|
275
|
+
if (i > 0)
|
|
276
|
+
headers.set(header.slice(0, i).trim(), header.slice(i + 1).trim());
|
|
277
|
+
}
|
|
278
|
+
if (values["dry-run"])
|
|
279
|
+
headers.set("x-spendctl-dry-run", "1");
|
|
280
|
+
const tokens = readAgentTokens(p);
|
|
281
|
+
const doFetch = wrapFetch({ url: sidecarUrl(p), agent, token: agent ? tokens[agent] : undefined });
|
|
282
|
+
const started = Date.now();
|
|
283
|
+
let response;
|
|
284
|
+
try {
|
|
285
|
+
response = await doFetch(url, {
|
|
286
|
+
method: values.method ?? (values.data ? "POST" : "GET"),
|
|
287
|
+
headers,
|
|
288
|
+
body: values.data,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
const cause = error.cause;
|
|
293
|
+
if (cause?.code === "UND_ERR_HEADERS_TIMEOUT") {
|
|
294
|
+
// Node's fetch stops waiting for response headers after 300 s; a held approval outlived it.
|
|
295
|
+
err(`${c.red("✖")} gave up after 5 minutes waiting for the sidecar's answer (Node's fetch limit). If a human approval is pending, run \`spendctl pending\`; keep approvals.holdMax under 5m.`);
|
|
296
|
+
return 1;
|
|
297
|
+
}
|
|
298
|
+
err(`${c.red("✖")} cannot reach the sidecar at ${sidecarUrl(p)} — is \`spendctl start\` running? (${error.message})`);
|
|
299
|
+
return 1;
|
|
300
|
+
}
|
|
301
|
+
const ms = Date.now() - started;
|
|
302
|
+
const refusal = await readRefusal(response);
|
|
303
|
+
const text = await response.text();
|
|
304
|
+
const decision = response.headers.get("x-spendctl-decision");
|
|
305
|
+
const amount = response.headers.get("x-spendctl-amount");
|
|
306
|
+
if (!values.quiet) {
|
|
307
|
+
if (refusal) {
|
|
308
|
+
const icon = refusal.error === "spendctl_dry_run"
|
|
309
|
+
? c.yellow("○")
|
|
310
|
+
: refusal.error === "spendctl_approval_required"
|
|
311
|
+
? c.yellow("⏸")
|
|
312
|
+
: c.red("✖");
|
|
313
|
+
out(`${icon} ${response.status} ${c.bold(refusal.error)} ${c.gray(`${ms}ms`)}`);
|
|
314
|
+
out(` ${refusal.message}`);
|
|
315
|
+
if (refusal.approval)
|
|
316
|
+
out(` ${c.dim("approve with:")} spendctl approve ${refusal.approval}`);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
const paid = decision === "settled"
|
|
320
|
+
? `${c.green("paid")} ${c.bold(amount ?? "")}`
|
|
321
|
+
: decision
|
|
322
|
+
? statusColor(decision)
|
|
323
|
+
: c.dim("no payment");
|
|
324
|
+
out(`${response.ok ? c.green("✔") : c.red("✖")} ${response.status} ${paid} ${c.gray(`${ms}ms`)}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (!refusal) {
|
|
328
|
+
try {
|
|
329
|
+
out(JSON.stringify(JSON.parse(text), null, 2));
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
out(text);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return response.ok ? 0 : refusal ? 2 : 1;
|
|
336
|
+
}
|
|
337
|
+
async function cmdPending(argv) {
|
|
338
|
+
const { values } = parse(argv, { ids: { type: "boolean" } });
|
|
339
|
+
const p = paths(values.config);
|
|
340
|
+
const { pending } = await admin(p).pending();
|
|
341
|
+
if (values.ids) {
|
|
342
|
+
for (const item of pending)
|
|
343
|
+
out(item.id);
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
if (!pending.length) {
|
|
347
|
+
out(c.dim("Nothing is waiting for approval."));
|
|
348
|
+
return 0;
|
|
349
|
+
}
|
|
350
|
+
out(table(pending.map((item) => [
|
|
351
|
+
c.bold(item.id),
|
|
352
|
+
item.agent,
|
|
353
|
+
`${item.host}${item.path}`,
|
|
354
|
+
item.usdMicros !== undefined ? formatUsd(BigInt(item.usdMicros)) : "-",
|
|
355
|
+
`${Math.max(0, Math.round((item.expiresAt - Date.now()) / 1000))}s`,
|
|
356
|
+
item.reasons.map((r) => r.message).join(" "),
|
|
357
|
+
]), ["ID", "AGENT", "ENDPOINT", "AMOUNT", "EXPIRES", "WHY"]));
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
async function cmdDecide(argv, action) {
|
|
361
|
+
const { values, positionals } = parse(argv, {
|
|
362
|
+
latest: { type: "boolean" },
|
|
363
|
+
all: { type: "boolean" },
|
|
364
|
+
note: { type: "string" },
|
|
365
|
+
});
|
|
366
|
+
const p = paths(values.config);
|
|
367
|
+
const client = admin(p);
|
|
368
|
+
let ids = positionals;
|
|
369
|
+
if (values.latest || values.all) {
|
|
370
|
+
const { pending } = await client.pending();
|
|
371
|
+
ids = values.all ? pending.map((item) => item.id) : pending.slice(0, 1).map((item) => item.id);
|
|
372
|
+
}
|
|
373
|
+
if (!ids.length) {
|
|
374
|
+
err(values.latest || values.all
|
|
375
|
+
? "Nothing is waiting for approval."
|
|
376
|
+
: `Usage: spendctl ${action} <approval-id> | --latest`);
|
|
377
|
+
return 1;
|
|
378
|
+
}
|
|
379
|
+
for (const id of ids) {
|
|
380
|
+
const result = action === "approve"
|
|
381
|
+
? await client.approve(id, values.note)
|
|
382
|
+
: await client.deny(id, values.note);
|
|
383
|
+
out(`${action === "approve" ? c.green("✔ approved") : c.red("✖ denied")} ${result.approval.id}`);
|
|
384
|
+
}
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
387
|
+
async function cmdBudget(argv) {
|
|
388
|
+
const { values } = parse(argv, { agent: { type: "string", short: "a" } });
|
|
389
|
+
const p = paths(values.config);
|
|
390
|
+
const summary = await admin(p).summary();
|
|
391
|
+
const agents = summary.agents.filter((a) => !values.agent || a.agent === values.agent);
|
|
392
|
+
for (const agent of agents) {
|
|
393
|
+
out(`${c.bold(agent.agent)} ${c.dim(agent.mode)}${agent.perRequest ? c.dim(` · max ${agent.perRequest}/request`) : ""}${agent.requireApprovalAbove ? c.dim(` · approval ≥ ${agent.requireApprovalAbove}`) : ""}`);
|
|
394
|
+
if (!agent.windows.length)
|
|
395
|
+
out(c.dim(" no time-window budgets"));
|
|
396
|
+
for (const w of agent.windows)
|
|
397
|
+
out(` ${w.window.padEnd(8)} ${w.spent.padStart(10)} of ${w.limit.padEnd(10)} ${c.dim(`${w.remaining} left`)}`);
|
|
398
|
+
}
|
|
399
|
+
const plan = summary.plan;
|
|
400
|
+
if (plan)
|
|
401
|
+
out(`${c.dim("plan")} ${plan.exhausted ? c.yellow(planLine(plan)) : planLine(plan)}`);
|
|
402
|
+
return 0;
|
|
403
|
+
}
|
|
404
|
+
async function cmdLicense(argv) {
|
|
405
|
+
const { values, positionals } = parse(argv);
|
|
406
|
+
const p = paths(values.config);
|
|
407
|
+
const [action, key] = positionals;
|
|
408
|
+
if (action === "set") {
|
|
409
|
+
if (!key) {
|
|
410
|
+
err("usage: spendctl license set <key>");
|
|
411
|
+
return 1;
|
|
412
|
+
}
|
|
413
|
+
const plan = parseLicenseKey(key);
|
|
414
|
+
writeLicenseKey(p, key);
|
|
415
|
+
const expires = plan.expiresAt ? new Date(plan.expiresAt).toISOString().slice(0, 10) : "never";
|
|
416
|
+
out(`${c.green("✔")} licence key stored in ${relative(process.cwd(), p.license)} (${TIER_LABELS[plan.tier]}${plan.org ? `, ${plan.org}` : ""}, expires ${expires})`);
|
|
417
|
+
out(c.dim("Restart the sidecar to apply it. $SPENDCTL_LICENSE_KEY, when set, takes precedence over the file."));
|
|
418
|
+
return 0;
|
|
419
|
+
}
|
|
420
|
+
if (action === "remove") {
|
|
421
|
+
out(removeLicenseKey(p)
|
|
422
|
+
? `${c.green("✔")} removed ${relative(process.cwd(), p.license)}; the sidecar runs with the Free limits after a restart`
|
|
423
|
+
: "No licence key file; nothing to remove.");
|
|
424
|
+
return 0;
|
|
425
|
+
}
|
|
426
|
+
if (action) {
|
|
427
|
+
err(`Unknown action "${action}" (use: spendctl license [set <key> | remove])`);
|
|
428
|
+
return 1;
|
|
429
|
+
}
|
|
430
|
+
const loaded = loadPlan(p);
|
|
431
|
+
if (loaded.warning)
|
|
432
|
+
err(`${c.yellow("!")} ${loaded.warning}`);
|
|
433
|
+
const where = loaded.source === "env"
|
|
434
|
+
? "$SPENDCTL_LICENSE_KEY"
|
|
435
|
+
: loaded.source === "file"
|
|
436
|
+
? relative(process.cwd(), p.license)
|
|
437
|
+
: "none (Free)";
|
|
438
|
+
out(`${c.dim("key ")} ${where}`);
|
|
439
|
+
const usage = await usageFor(p, loaded.plan);
|
|
440
|
+
out(`${c.dim("plan ")} ${planLine(usage)}`);
|
|
441
|
+
if (usage.exhausted)
|
|
442
|
+
out(c.yellow(`This month's cap is reached; new payments are refused until ${usage.resets}.`));
|
|
443
|
+
const warning = expiryWarning(loaded.plan);
|
|
444
|
+
if (warning)
|
|
445
|
+
out(c.yellow(warning));
|
|
446
|
+
return 0;
|
|
447
|
+
}
|
|
448
|
+
async function cmdLedger(argv) {
|
|
449
|
+
const { values } = parse(argv, { agent: { type: "string", short: "a" }, limit: { type: "string", short: "n" } });
|
|
450
|
+
const p = paths(values.config);
|
|
451
|
+
const { attempts } = await admin(p).ledger({
|
|
452
|
+
agent: values.agent,
|
|
453
|
+
limit: Number(values.limit ?? 20),
|
|
454
|
+
});
|
|
455
|
+
if (!attempts.length) {
|
|
456
|
+
out(c.dim("The ledger is empty."));
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
out(table(attempts.map((a) => [
|
|
460
|
+
c.gray(a.created_at_utc.replace("T", " ").slice(5, 19)),
|
|
461
|
+
a.id,
|
|
462
|
+
a.agent,
|
|
463
|
+
`${a.method} ${a.host}${a.path}`,
|
|
464
|
+
a.usd_value ? `$${a.usd_value}` : "-",
|
|
465
|
+
statusColor(a.status),
|
|
466
|
+
a.tx_hash ? c.gray(short(a.tx_hash, 16)) : c.dim(a.reasons),
|
|
467
|
+
]), ["TIME (UTC)", "ID", "AGENT", "ENDPOINT", "USD", "STATUS", "TX / REASON"]));
|
|
468
|
+
return 0;
|
|
469
|
+
}
|
|
470
|
+
async function cmdExport(argv) {
|
|
471
|
+
const { values } = parse(argv, {
|
|
472
|
+
format: { type: "string", short: "f" },
|
|
473
|
+
out: { type: "string", short: "o" },
|
|
474
|
+
agent: { type: "string", short: "a" },
|
|
475
|
+
since: { type: "string" },
|
|
476
|
+
});
|
|
477
|
+
const p = paths(values.config);
|
|
478
|
+
const format = values.format === "json" ? "json" : "csv";
|
|
479
|
+
const body = await admin(p).export(format, {
|
|
480
|
+
agent: values.agent,
|
|
481
|
+
since: values.since,
|
|
482
|
+
});
|
|
483
|
+
const text = typeof body === "string" ? body : `${JSON.stringify(body, null, 2)}\n`;
|
|
484
|
+
if (values.out) {
|
|
485
|
+
writeFileSync(String(values.out), text);
|
|
486
|
+
const rows = format === "csv" ? Math.max(0, text.trim().split("\n").length - 1) : JSON.parse(text).length;
|
|
487
|
+
out(`${c.green("✔")} wrote ${rows} row(s) to ${values.out}`);
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
process.stdout.write(text);
|
|
491
|
+
}
|
|
492
|
+
return 0;
|
|
493
|
+
}
|
|
494
|
+
async function cmdTrace(argv) {
|
|
495
|
+
const { values, positionals } = parse(argv);
|
|
496
|
+
if (!positionals[0]) {
|
|
497
|
+
err("Usage: spendctl trace <attempt-id>");
|
|
498
|
+
return 1;
|
|
499
|
+
}
|
|
500
|
+
const p = paths(values.config);
|
|
501
|
+
out((await admin(p).trace(positionals[0])).text);
|
|
502
|
+
return 0;
|
|
503
|
+
}
|
|
504
|
+
async function cmdResolve(argv) {
|
|
505
|
+
const { values, positionals } = parse(argv, {
|
|
506
|
+
settled: { type: "boolean" },
|
|
507
|
+
failed: { type: "boolean" },
|
|
508
|
+
tx: { type: "string" },
|
|
509
|
+
});
|
|
510
|
+
const id = positionals[0];
|
|
511
|
+
if (!id || values.settled === values.failed) {
|
|
512
|
+
err("Usage: spendctl resolve <attempt-id> --settled|--failed [--tx HASH]");
|
|
513
|
+
return 1;
|
|
514
|
+
}
|
|
515
|
+
const p = paths(values.config);
|
|
516
|
+
await admin(p).resolve(id, values.settled ? "settled" : "failed", values.tx);
|
|
517
|
+
out(`${c.green("✔")} ${id} marked ${values.settled ? "settled" : "failed"}`);
|
|
518
|
+
return 0;
|
|
519
|
+
}
|
|
520
|
+
async function cmdDashboard(argv) {
|
|
521
|
+
const { values } = parse(argv, { open: { type: "boolean" } });
|
|
522
|
+
const p = paths(values.config);
|
|
523
|
+
const url = `${sidecarUrl(p)}/_spendctl/dashboard#token=${readAdminToken(p)}`;
|
|
524
|
+
out(url);
|
|
525
|
+
if (values.open)
|
|
526
|
+
spawn(process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open", [url], {
|
|
527
|
+
stdio: "ignore",
|
|
528
|
+
detached: true,
|
|
529
|
+
}).unref();
|
|
530
|
+
return 0;
|
|
531
|
+
}
|
|
532
|
+
async function cmdRun(argv) {
|
|
533
|
+
const split = argv.indexOf("--");
|
|
534
|
+
const own = split === -1 ? argv : argv.slice(0, split);
|
|
535
|
+
const command = split === -1 ? [] : argv.slice(split + 1);
|
|
536
|
+
const { values } = parse(own, { agent: { type: "string", short: "a" } });
|
|
537
|
+
if (!command.length || !values.agent) {
|
|
538
|
+
err("Usage: spendctl run --agent <name> -- <command...>");
|
|
539
|
+
return 1;
|
|
540
|
+
}
|
|
541
|
+
const p = paths(values.config);
|
|
542
|
+
const base = new URL(sidecarUrl(p));
|
|
543
|
+
const token = readAgentTokens(p)[String(values.agent)];
|
|
544
|
+
const proxy = `${base.protocol}//${encodeURIComponent(String(values.agent))}${token ? `:${token}` : ""}@${base.host}`;
|
|
545
|
+
const child = spawn(command[0], command.slice(1), {
|
|
546
|
+
stdio: "inherit",
|
|
547
|
+
env: {
|
|
548
|
+
...process.env,
|
|
549
|
+
HTTP_PROXY: proxy,
|
|
550
|
+
http_proxy: proxy,
|
|
551
|
+
NODE_USE_ENV_PROXY: "1",
|
|
552
|
+
SPENDCTL_URL: base.origin,
|
|
553
|
+
SPENDCTL_AGENT: String(values.agent),
|
|
554
|
+
...(token ? { SPENDCTL_AGENT_TOKEN: token } : {}),
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
return new Promise((done) => child.on("exit", (code) => done(code ?? 1)));
|
|
558
|
+
}
|
|
559
|
+
async function cmdToken(argv) {
|
|
560
|
+
const { values, positionals } = parse(argv);
|
|
561
|
+
const agent = positionals[0];
|
|
562
|
+
if (!agent) {
|
|
563
|
+
err("Usage: spendctl token <agent>");
|
|
564
|
+
return 1;
|
|
565
|
+
}
|
|
566
|
+
const p = paths(values.config);
|
|
567
|
+
const tokens = readAgentTokens(p);
|
|
568
|
+
tokens[agent] ??= newToken(16);
|
|
569
|
+
writeAgentTokens(p, tokens);
|
|
570
|
+
out(tokens[agent]);
|
|
571
|
+
return 0;
|
|
572
|
+
}
|
|
573
|
+
async function cmdWallet(argv) {
|
|
574
|
+
const { values, positionals } = parse(argv, { amount: { type: "string" } });
|
|
575
|
+
const p = paths(values.config);
|
|
576
|
+
loadDotenv(p.root);
|
|
577
|
+
const config = loadConfig(p.config);
|
|
578
|
+
const action = positionals[0] ?? "address";
|
|
579
|
+
const signer = await createSigner(config.signer);
|
|
580
|
+
if (action === "address") {
|
|
581
|
+
out(`${signer.address} ${c.dim(signer.description)}`);
|
|
582
|
+
return 0;
|
|
583
|
+
}
|
|
584
|
+
if (action === "fund") {
|
|
585
|
+
if (config.signer.type !== "cdp") {
|
|
586
|
+
err("`wallet fund` uses the CDP faucet and needs signer.type: cdp.");
|
|
587
|
+
return 1;
|
|
588
|
+
}
|
|
589
|
+
const result = await requestTestnetUsdc(signer.address);
|
|
590
|
+
out(`${c.green("✔")} requested 1 testnet USDC for ${signer.address}`);
|
|
591
|
+
out(` https://sepolia.basescan.org/tx/${result.transactionHash}`);
|
|
592
|
+
return 0;
|
|
593
|
+
}
|
|
594
|
+
err("Usage: spendctl wallet [address|fund]");
|
|
595
|
+
return 1;
|
|
596
|
+
}
|
|
597
|
+
async function cmdMcp() {
|
|
598
|
+
const { serveStdio } = await import("@spendctl/mcp-server");
|
|
599
|
+
await serveStdio();
|
|
600
|
+
await new Promise(() => undefined);
|
|
601
|
+
return 0;
|
|
602
|
+
}
|
|
603
|
+
const COMMANDS = {
|
|
604
|
+
init: cmdInit,
|
|
605
|
+
start: cmdStart,
|
|
606
|
+
mock: cmdMock,
|
|
607
|
+
fetch: cmdFetch,
|
|
608
|
+
pending: cmdPending,
|
|
609
|
+
approve: (argv) => cmdDecide(argv, "approve"),
|
|
610
|
+
deny: (argv) => cmdDecide(argv, "deny"),
|
|
611
|
+
budget: cmdBudget,
|
|
612
|
+
ledger: cmdLedger,
|
|
613
|
+
export: cmdExport,
|
|
614
|
+
trace: cmdTrace,
|
|
615
|
+
resolve: cmdResolve,
|
|
616
|
+
dashboard: cmdDashboard,
|
|
617
|
+
run: cmdRun,
|
|
618
|
+
token: cmdToken,
|
|
619
|
+
wallet: cmdWallet,
|
|
620
|
+
license: cmdLicense,
|
|
621
|
+
mcp: cmdMcp,
|
|
622
|
+
};
|
|
623
|
+
export async function main(argv) {
|
|
624
|
+
const [command, ...rest] = argv;
|
|
625
|
+
if (!command || command === "-h" || command === "--help" || command === "help") {
|
|
626
|
+
out(HELP);
|
|
627
|
+
return 0;
|
|
628
|
+
}
|
|
629
|
+
if (command === "-v" || command === "--version") {
|
|
630
|
+
out(VERSION);
|
|
631
|
+
return 0;
|
|
632
|
+
}
|
|
633
|
+
const handler = COMMANDS[command];
|
|
634
|
+
if (!handler) {
|
|
635
|
+
err(`Unknown command "${command}". Run \`spendctl --help\`.`);
|
|
636
|
+
return 1;
|
|
637
|
+
}
|
|
638
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
639
|
+
out(HELP);
|
|
640
|
+
return 0;
|
|
641
|
+
}
|
|
642
|
+
try {
|
|
643
|
+
return await handler(rest);
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
if (error instanceof ConfigError || error instanceof LicenseError || error instanceof PlanError)
|
|
647
|
+
err(c.red(error.message));
|
|
648
|
+
else
|
|
649
|
+
err(`${c.red("✖")} ${error.message}`);
|
|
650
|
+
return 1;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
//# sourceMappingURL=index.js.map
|