Skip to content

Commit d02b520

Browse files
MajorTalclaude
andcommitted
feat(status): redesign account-status output shape
Rename wallet identity fields name/label -> local_label/server_label across run402 status, r.whoami() (WhoAmI type), the MCP status tool, and wallets list/current. Group balances under a single `balances` object (on_chain_usd_micros + rail-aware on_chain_token, prepaid_credit_usd_micros, held_usd_micros), drop the redundant `allowance` block and the stale `funded` boolean, and move the no-allowance empty-state to { wallet: null, hint }. Align run402 init to the same wallet/balances shape; add held_usd_micros? to the BillingBalance SDK type. BREAKING: run402 status / init JSON and the WhoAmI SDK type field names change. Pre-launch; no external consumers. Implements OpenSpec change redesign-status-output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4291d93 commit d02b520

22 files changed

Lines changed: 463 additions & 113 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ Every subcommand prints JSON to stdout, JSON errors to stderr, exits 0 on succes
310310

311311
```bash
312312
run402 init # one-shot allowance + faucet + tier check
313-
run402 status # account snapshot (allowance, balance, tier, projects)
313+
run402 status # account snapshot (wallet, rail, balances, tier, projects)
314314
run402 projects provision --name my-app
315315
run402 projects sql <id> "CREATE TABLE …"
316316
run402 projects validate-expose <id> --file manifest.json

cli-argv.test.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1576,7 +1576,7 @@ describe("CLI JSON-only output contract (v3.x cleanup)", () => {
15761576
if (out.length > 0) {
15771577
const parsed = JSON.parse(out);
15781578
assert.equal(typeof parsed.config_dir, "string", "stdout JSON should include config_dir");
1579-
assert.equal(typeof parsed.allowance, "object", "stdout JSON should include allowance object");
1579+
assert.equal(typeof parsed.wallet, "object", "stdout JSON should include wallet object");
15801580
}
15811581
const err = stderr.join("\n");
15821582
assert.match(err, /\bConfig\b/, `human banner should be on stderr, got: ${err.slice(0, 300)}`);

cli-e2e.test.mjs

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3888,10 +3888,16 @@ describe("CLI e2e happy path", () => {
38883888
}
38893889
assert.ok(typeof parsed === "object" && parsed !== null, "stdout must be a JSON object");
38903890
assert.ok(typeof parsed.config_dir === "string", "should include config_dir");
3891-
assert.ok(typeof parsed.allowance === "object" && parsed.allowance?.address, "should include allowance.address");
3891+
assert.ok(typeof parsed.wallet === "object" && parsed.wallet?.address, "should include wallet.address");
3892+
assert.ok(typeof parsed.wallet.local_label === "string", "wallet should include local_label");
3893+
assert.ok(Object.prototype.hasOwnProperty.call(parsed.wallet, "server_label"), "wallet should include server_label");
3894+
assert.ok(!Object.prototype.hasOwnProperty.call(parsed.wallet, "funded"), "wallet should not include stale funded");
38923895
assert.ok(parsed.rail === "x402" || parsed.rail === "mpp", `should include rail, got: ${parsed.rail}`);
38933896
assert.ok(typeof parsed.network === "string", "should include network");
3894-
assert.ok(Object.prototype.hasOwnProperty.call(parsed, "balance"), "should include balance field");
3897+
assert.ok(typeof parsed.balances === "object" && parsed.balances !== null, "should include balances object");
3898+
assert.equal(parsed.balances.on_chain_token, parsed.rail === "mpp" ? "pathUSD" : "USDC", "on_chain_token should track rail");
3899+
assert.ok(!Object.prototype.hasOwnProperty.call(parsed, "balance"), "should not expose ambiguous balance field");
3900+
assert.ok(!Object.prototype.hasOwnProperty.call(parsed, "allowance"), "should not expose allowance block");
38953901
assert.ok(Object.prototype.hasOwnProperty.call(parsed, "tier"), "should include tier field");
38963902
assert.equal(typeof parsed.projects_saved, "number", "should include projects_saved (number)");
38973903
assert.ok(typeof parsed.next_step === "string", "should include next_step");
@@ -3900,27 +3906,44 @@ describe("CLI e2e happy path", () => {
39003906
assert.ok(stderr.includes("Config"), `stderr should contain human lines, got: ${stderr}`);
39013907
});
39023908

3903-
it("status has billing, wallet_balance_usd_micros, and project_id fields (GH-32)", async () => {
3909+
it("status exposes wallet identity, grouped balances, and project_id fields", async () => {
39043910
const { run } = await import("./cli/lib/status.mjs");
39053911
captureStart();
39063912
await run();
39073913
captureStop();
39083914
const out = captured();
39093915
const data = JSON.parse(out);
3910-
assert.ok(data.allowance, "should include allowance");
3911-
assert.ok(data.allowance.address, "should include allowance address");
3916+
// Wallet identity uses local_label / server_label (not name / label).
3917+
assert.ok(data.wallet, "should include wallet");
3918+
assert.ok(data.wallet.address, "should include wallet.address");
3919+
assert.ok(typeof data.wallet.local_label === "string", "wallet should include local_label");
3920+
assert.ok(Object.prototype.hasOwnProperty.call(data.wallet, "server_label"), "wallet should include server_label");
3921+
assert.ok(!Object.prototype.hasOwnProperty.call(data.wallet, "name"), "wallet should not use legacy name field");
3922+
assert.ok(!Object.prototype.hasOwnProperty.call(data.wallet, "label"), "wallet should not use legacy label field");
39123923
assert.ok(Array.isArray(data.projects), "should include projects array");
3913-
// GH-32 sub-issue 2: rename balance → billing, add wallet_balance_usd_micros
3924+
// The duplicate allowance block and the stale funded boolean are gone.
3925+
assert.ok(!Object.prototype.hasOwnProperty.call(data, "allowance"),
3926+
`status should not expose duplicate "allowance" block; got: ${JSON.stringify(data)}`);
3927+
assert.ok(!Object.prototype.hasOwnProperty.call(data, "funded"),
3928+
`status should not expose stale "funded" boolean; got: ${JSON.stringify(data)}`);
3929+
// Balances are grouped; no ambiguous top-level balance / wallet_balance_usd_micros / billing.
39143930
assert.ok(!Object.prototype.hasOwnProperty.call(data, "balance"),
39153931
`status should not expose ambiguous "balance" field; got: ${JSON.stringify(data)}`);
3916-
assert.ok(Object.prototype.hasOwnProperty.call(data, "billing"),
3917-
`status should expose "billing" field; got: ${JSON.stringify(data)}`);
3918-
assert.ok(Object.prototype.hasOwnProperty.call(data, "wallet_balance_usd_micros"),
3919-
`status should expose "wallet_balance_usd_micros"; got: ${JSON.stringify(data)}`);
3920-
const wb = data.wallet_balance_usd_micros;
3932+
assert.ok(!Object.prototype.hasOwnProperty.call(data, "wallet_balance_usd_micros"),
3933+
`status should fold on-chain balance into balances; got: ${JSON.stringify(data)}`);
3934+
assert.ok(!Object.prototype.hasOwnProperty.call(data, "billing"),
3935+
`status should fold prepaid credit into balances; got: ${JSON.stringify(data)}`);
3936+
assert.ok(typeof data.balances === "object" && data.balances !== null, "should include balances object");
3937+
const wb = data.balances.on_chain_usd_micros;
39213938
assert.ok(wb === null || typeof wb === "number",
3922-
`wallet_balance_usd_micros must be number or null; got: ${JSON.stringify(wb)}`);
3923-
// GH-32 sub-issue 4: projects entries must use project_id
3939+
`balances.on_chain_usd_micros must be number or null; got: ${JSON.stringify(wb)}`);
3940+
assert.equal(data.balances.on_chain_token, data.rail === "mpp" ? "pathUSD" : "USDC",
3941+
`on_chain_token should track rail; got: ${JSON.stringify(data.balances)}`);
3942+
assert.ok(Object.prototype.hasOwnProperty.call(data.balances, "prepaid_credit_usd_micros"),
3943+
"balances should include prepaid_credit_usd_micros");
3944+
assert.ok(Object.prototype.hasOwnProperty.call(data.balances, "held_usd_micros"),
3945+
"balances should include held_usd_micros");
3946+
// projects entries must use project_id
39243947
for (const p of data.projects) {
39253948
assert.ok(typeof p.project_id === "string" && p.project_id.length > 0,
39263949
`each project should have a project_id; got: ${JSON.stringify(p)}`);
@@ -4049,11 +4072,12 @@ describe("CLI e2e happy path", () => {
40494072
assert.equal(allowance.rail, "mpp", "rail should be mpp");
40504073
});
40514074

4052-
// GH-81: after MPP faucet succeeds, the JSON summary must reflect funded=true
4053-
// and the polled balance. Previously `summary.allowance` was captured before
4054-
// the faucet branch ran, so the JSON reported `funded: false` and
4055-
// `usd_micros: 0` even when the human-readable lines said "funded".
4056-
it("init mpp reports funded=true after faucet settles (GH-81)", async () => {
4075+
// GH-81: after MPP faucet succeeds, the JSON summary must reflect the polled
4076+
// on-chain balance. Previously the balance was captured before the faucet
4077+
// branch ran, so the JSON reported `usd_micros: 0` even when the
4078+
// human-readable lines said "funded". On-chain balance now lives under
4079+
// `balances.on_chain_usd_micros` (the old `funded` boolean was removed).
4080+
it("init mpp reflects polled on-chain balance after faucet settles (GH-81)", async () => {
40574081
tempoRpcCallCount = 0; // first eth_call returns 0 → triggers faucet path
40584082
const { run } = await import("./cli/lib/init.mjs");
40594083
captureStart();
@@ -4062,11 +4086,9 @@ describe("CLI e2e happy path", () => {
40624086
const stdout = capturedStdout();
40634087
const parsed = JSON.parse(stdout);
40644088
assert.equal(parsed.rail, "mpp", `rail must be mpp; got: ${parsed.rail}`);
4065-
assert.equal(parsed.allowance.funded, true,
4066-
`summary.allowance.funded must be true after successful faucet; got: ${JSON.stringify(parsed.allowance)}`);
4067-
assert.equal(parsed.balance.symbol, "pathUSD");
4068-
assert.ok(parsed.balance.usd_micros > 0,
4069-
`summary.balance.usd_micros must reflect polled balance (>0); got: ${parsed.balance.usd_micros}`);
4089+
assert.equal(parsed.balances.on_chain_token, "pathUSD");
4090+
assert.ok(parsed.balances.on_chain_usd_micros > 0,
4091+
`balances.on_chain_usd_micros must reflect polled balance (>0) after faucet; got: ${parsed.balances.on_chain_usd_micros}`);
40704092
});
40714093

40724094
it("allowance status (MPP rail)", async () => {
@@ -5020,11 +5042,11 @@ describe("CLI canonical error envelope (GH-215, GH-174)", () => {
50205042

50215043
describe("CLI status local-state inspection (cli-output-shape)", () => {
50225044
// Per cli-output-shape spec: absence of local state is an informational
5023-
// read, not an error. status exits 0 with `{ allowance: null, hint: "..." }`;
5045+
// read, not an error. status exits 0 with `{ wallet: null, hint: "..." }`;
50245046
// allowance status exits 0 with `{ wallet: null, hint: "..." }`. The
50255047
// previous GH-191 contract (exit 1 + `status: "no_allowance"`) was retired
50265048
// in v3.0 as part of the CLI envelope normalization.
5027-
it("status with no allowance emits typed null allowance and exits 0", async () => {
5049+
it("status with no allowance emits typed null wallet and exits 0", async () => {
50285050
const { ALLOWANCE_FILE } = await import("./cli/lib/config.mjs");
50295051
try { rmSync(ALLOWANCE_FILE, { force: true }); } catch {}
50305052
const { run } = await import("./cli/lib/status.mjs");
@@ -5041,7 +5063,7 @@ describe("CLI status local-state inspection (cli-output-shape)", () => {
50415063
assert.ok(line, `should emit payload on stdout, got: ${stdout}`);
50425064
const parsed = JSON.parse(line);
50435065
assert.equal(parsed.status, undefined, "must not emit a top-level status field");
5044-
assert.equal(parsed.allowance, null, "allowance must be typed null when absent");
5066+
assert.equal(parsed.wallet, null, "wallet must be typed null when absent");
50455067
assert.ok(parsed.hint && /run402 init/.test(parsed.hint), `hint must guide to next step, got: ${parsed.hint}`);
50465068
});
50475069

@@ -5161,7 +5183,7 @@ describe("CLI malformed allowance.json (GH-194)", () => {
51615183
`message should mention privateKey; got: ${parsed.message}`);
51625184
});
51635185

5164-
it("status with unparseable allowance.json still surfaces as typed null allowance (existing UX preserved)", async () => {
5186+
it("status with unparseable allowance.json still surfaces as typed null wallet (existing UX preserved)", async () => {
51655187
const { ALLOWANCE_FILE } = await import("./cli/lib/config.mjs");
51665188
const fs = await import("node:fs");
51675189
fs.writeFileSync(ALLOWANCE_FILE, "not json");
@@ -5180,8 +5202,8 @@ describe("CLI malformed allowance.json (GH-194)", () => {
51805202
assert.ok(line, `should emit payload on stdout, got: ${stdout}`);
51815203
const parsed = JSON.parse(line);
51825204
assert.equal(parsed.status, undefined, "must not emit a top-level status field");
5183-
assert.equal(parsed.allowance, null,
5184-
`unparseable JSON should surface as typed null allowance (no BAD_ALLOWANCE_FILE error envelope); got: ${JSON.stringify(parsed)}`);
5205+
assert.equal(parsed.wallet, null,
5206+
`unparseable JSON should surface as typed null wallet (no BAD_ALLOWANCE_FILE error envelope); got: ${JSON.stringify(parsed)}`);
51855207
});
51865208
});
51875209

cli-integration.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,9 @@ describe("CLI integration (live API, no mocks)", { timeout: 180_000 }, () => {
510510
await run();
511511
captureStop();
512512
const data = capturedJson();
513-
assert.ok(data.allowance, "should include allowance");
513+
assert.ok(data.wallet, "should include wallet");
514+
assert.ok(data.wallet.address, "should include wallet.address");
515+
assert.ok(data.balances, "should include balances");
514516
assert.ok(Array.isArray(data.projects), "should include projects");
515517
});
516518

cli-wallets.test.mjs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ describe("wallets — lifecycle", () => {
6060
it("new → list → rm", () => {
6161
assert.equal(run(["wallets", "new", "kychon"]).status, 0);
6262
const list = jsonOut(run(["wallets", "list"]));
63-
assert.deepEqual(list.map((w) => w.name).sort(), ["kychon"]);
64-
assert.equal(list[0].label, "kychon");
63+
assert.deepEqual(list.map((w) => w.local_label).sort(), ["kychon"]);
64+
assert.equal(list[0].server_label, "kychon");
6565
// rm requires --yes
6666
const noConfirm = run(["wallets", "rm", "kychon"]);
6767
assert.notEqual(noConfirm.status, 0);
@@ -76,7 +76,7 @@ describe("wallets — lifecycle", () => {
7676
assert.equal(run(["wallets", "rename", "default", "kychon"]).status, 0);
7777
assert.ok(!existsSync(join(configDir, "allowance.json")), "root allowance.json migrated away");
7878
assert.ok(existsSync(join(configDir, "profiles", "kychon", "allowance.json")));
79-
assert.deepEqual(jsonOut(run(["wallets", "list"])).map((w) => w.name), ["kychon"]);
79+
assert.deepEqual(jsonOut(run(["wallets", "list"])).map((w) => w.local_label), ["kychon"]);
8080
});
8181
});
8282

@@ -88,20 +88,20 @@ describe("wallets — selection precedence", () => {
8888

8989
it("flag wins and reports source=flag", () => {
9090
const cur = jsonOut(run(["--wallet", "kychon", "wallets", "current"]));
91-
assert.equal(cur.name, "kychon");
91+
assert.equal(cur.local_label, "kychon");
9292
assert.equal(cur.source, "flag");
9393
});
9494

9595
it("env selects when no flag", () => {
9696
const cur = jsonOut(run(["wallets", "current"], { env: { RUN402_WALLET: "client-a" } }));
97-
assert.equal(cur.name, "client-a");
97+
assert.equal(cur.local_label, "client-a");
9898
assert.equal(cur.source, "env");
9999
});
100100

101101
it("directory binding selects when no flag/env", () => {
102102
writeFileSync(join(workDir, ".run402.json"), JSON.stringify({ wallet: "client-a" }));
103103
const cur = jsonOut(run(["wallets", "current"]));
104-
assert.equal(cur.name, "client-a");
104+
assert.equal(cur.local_label, "client-a");
105105
assert.equal(cur.source, "binding");
106106
});
107107

@@ -111,19 +111,19 @@ describe("wallets — selection precedence", () => {
111111
const sub = join(workDir, "api");
112112
mkdirSync(sub);
113113
const cur = jsonOut(run(["wallets", "current"], { cwd: sub }));
114-
assert.equal(cur.name, "kychon");
114+
assert.equal(cur.local_label, "kychon");
115115
});
116116

117117
it("global `wallets use` applies when no flag/env/binding", () => {
118118
assert.equal(run(["wallets", "use", "kychon"]).status, 0);
119119
const cur = jsonOut(run(["wallets", "current"]));
120-
assert.equal(cur.name, "kychon");
120+
assert.equal(cur.local_label, "kychon");
121121
assert.equal(cur.source, "config");
122122
});
123123

124124
it("falls back to default when nothing selects", () => {
125125
const cur = jsonOut(run(["wallets", "current"]));
126-
assert.equal(cur.name, "default");
126+
assert.equal(cur.local_label, "default");
127127
assert.equal(cur.source, "default");
128128
});
129129
});

cli/lib/init.mjs

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { readAllowance, saveAllowance, loadKeyStore, configDir } from "./config.mjs";
22
import { getSdk } from "./sdk.mjs";
33
import { fail } from "./sdk-errors.mjs";
4+
import { getActiveProfile } from "../core-dist/config.js";
5+
import { readMeta } from "../core-dist/profiles.js";
46
import { mkdirSync } from "fs";
57

68
const USDC_ABI = [{ name: "balanceOf", type: "function", stateMutability: "view", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }] }];
@@ -25,7 +27,7 @@ Options:
2527
idempotent and does not need this flag.
2628
2729
Output:
28-
Stdout is a JSON summary { config_dir, allowance, rail, network, balance,
30+
Stdout is a JSON summary { config_dir, wallet, rail, network, balances,
2931
tier, projects_saved, next_step }. Progress lines (Config / Allowance /
3032
Balance / Tier / Next) go to stderr so a human re-running interactively
3133
sees what's happening while a script piping stdout to jq stays clean.
@@ -87,10 +89,10 @@ export async function run(args = []) {
8789
const line = (label, value) => write(` ${label.padEnd(10)} ${value}`);
8890
const summary = {
8991
config_dir: CONFIG_DIR,
90-
allowance: null,
92+
wallet: null,
9193
rail: null,
9294
network: null,
93-
balance: null,
95+
balances: null,
9496
tier: null,
9597
projects_saved: 0,
9698
next_step: null,
@@ -124,7 +126,9 @@ export async function run(args = []) {
124126
line("Allowance", short(allowance.address));
125127
}
126128

127-
summary.allowance = { address: allowance.address, funded: allowance.funded || false };
129+
const walletName = getActiveProfile();
130+
const walletMeta = readMeta(walletName);
131+
summary.wallet = { local_label: walletName, server_label: walletMeta?.label ?? null, address: allowance.address };
128132
summary.network = isMpp ? "tempo-moderato" : "base-sepolia";
129133
summary.rail = isMpp ? "mpp" : "x402";
130134

@@ -172,7 +176,6 @@ export async function run(args = []) {
172176
} catch {}
173177
}
174178
saveAllowance({ ...allowance, funded: true, lastFaucet: new Date().toISOString() });
175-
summary.allowance.funded = true;
176179
if (balance > 0) {
177180
line("Balance", `${(balance / 1e6).toFixed(2)} pathUSD (funded)`);
178181
} else {
@@ -186,9 +189,7 @@ export async function run(args = []) {
186189
}
187190
} else {
188191
line("Balance", `${(balance / 1e6).toFixed(2)} pathUSD`);
189-
summary.allowance.funded = balance > 0;
190192
}
191-
summary.balance = { symbol: "pathUSD", usd_micros: balance };
192193
} else {
193194
// Base Sepolia: read USDC balance (existing behavior)
194195
const { createPublicClient, http } = await import("viem");
@@ -214,7 +215,6 @@ export async function run(args = []) {
214215
} catch {}
215216
}
216217
saveAllowance({ ...allowance, funded: true, lastFaucet: new Date().toISOString() });
217-
summary.allowance.funded = true;
218218
if (balance > 0) {
219219
line("Balance", `${(balance / 1e6).toFixed(2)} USDC (funded)`);
220220
} else {
@@ -225,11 +225,21 @@ export async function run(args = []) {
225225
}
226226
} else {
227227
line("Balance", `${(balance / 1e6).toFixed(2)} USDC`);
228-
summary.allowance.funded = balance > 0;
229228
}
230-
summary.balance = { symbol: "USDC", usd_micros: balance };
231229
}
232230

231+
// Balances mirror `run402 status`: the on-chain figure above plus the
232+
// Run402-held prepaid credit (rail-independent). Prepaid credit is fetched
233+
// best-effort so a billing read failure never blocks setup.
234+
const billing = await getSdk().billing.checkBalance(allowance.address).catch(() => null);
235+
const hasBilling = billing && billing.exists !== false;
236+
summary.balances = {
237+
on_chain_usd_micros: balance,
238+
on_chain_token: isMpp ? "pathUSD" : "USDC",
239+
prepaid_credit_usd_micros: hasBilling ? billing.available_usd_micros : null,
240+
held_usd_micros: hasBilling ? (billing.held_usd_micros ?? 0) : null,
241+
};
242+
233243
// Show note if switching rails
234244
if (previousRail && previousRail !== (isMpp ? "mpp" : "x402")) {
235245
const prev = previousRail === "mpp" ? "Tempo pathUSD" : "Base Sepolia USDC";

0 commit comments

Comments
 (0)