Skip to content

Commit e2e5dd6

Browse files
MajorTalclaude
andcommitted
feat(cli): normalize stdout envelope — drop status:ok wrapper, type local-state nulls
Unifies the CLI's stdout shape across every subcommand. Before this change, about half of subcommands wrapped success payloads as `{ status: "ok", ...payload }` and the other half emitted the raw payload — a split that grew organically and followed no documented rule. After this change every CLI success path emits its natural payload directly, the contract is explicit in cli/llms-cli.txt's new Output Contract section, and a drift-protection test (cli-output-contract.test.mjs, wired into npm test) fails CI on any new top-level `JSON.stringify({ status: ... })` outside cli/lib/sdk-errors.mjs. Compatibility notes for anyone parsing CLI output: - Drop `.status === "ok"` checks; gate on exit code instead. - Mutations with no natural payload now echo identifier + state field, e.g. `{ key, project_id, set: true }` instead of `{ status: "ok", message: "..." }`. - `run402 status` and `run402 allowance status` move special statuses into typed nullable payload fields (`{ allowance: null, hint }` / `{ wallet: null, hint }`) and now exit 0 when absent (was exit 1 with `status: "no_allowance"` / `status: "no_wallet"`). Absence is informational, not an error. - Stderr error envelopes are unchanged. SDK return types are unchanged. MCP tool output shapes are unchanged. Touched 68 emit sites across 19 cli/lib/*.mjs files, ~50 e2e test assertions, cli/llms-cli.txt (Output Contract section + per-command sweep), openclaw/SKILL.md, cli/README.md, and CHANGELOG.md. Three inline `console.error(JSON.stringify({ status: "error", ... }))` sites in init.mjs, projects.mjs, sites.mjs now route through fail() in sdk-errors.mjs for envelope consistency. OpenSpec change: openspec/changes/cli-drop-status-envelope/ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 365038c commit e2e5dd6

41 files changed

Lines changed: 800 additions & 169 deletions

Some content is hidden

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

CHANGELOG.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,55 @@
22

33
All notable changes to `@run402/sdk`, `run402` (CLI), and `run402-mcp`. Versions are kept in lockstep across the three packages in this repo. `@run402/functions` lives in the private gateway monorepo and publishes on its own cadence.
44

5+
## 2.16.0 — unreleased — CLI stdout envelope normalization
6+
7+
Drops the `status: "ok"` wrapper from every `run402` CLI success-path stdout emission, unifying an envelope that was applied to roughly half the subcommands and absent from the other half. See [openspec change `cli-drop-status-envelope`](openspec/changes/cli-drop-status-envelope/proposal.md) for the full design.
8+
9+
`@run402/sdk` and `run402-mcp` have **no code changes** in this release. Only the CLI's machine-readable stdout shape moved. Per the lockstep release policy, all three packages bump to 2.16.0 together.
10+
11+
### Compatibility note (read this if you parse CLI JSON output)
12+
13+
The `run402` CLI was agent-first and JSON-only by design, but its stdout envelope was never documented — about half of subcommands wrapped success payloads as `{ status: "ok", ...payload }`, the other half emitted the raw payload. The wrapper has been dropped across the board, the contract is now explicit in [`cli/llms-cli.txt`](cli/llms-cli.txt), and a drift-protection test (`cli-output-contract.test.mjs`, wired into `npm test`) prevents the inconsistency from coming back.
14+
15+
If you have automation parsing CLI output:
16+
17+
- **Drop any `.status === "ok"` checks.** They were never load-bearing for half the commands, and now load-bear for none. Gate on exit code (`0` = success, non-zero = error) instead.
18+
- **Mutations with no natural payload now echo identifier + state field:**
19+
20+
```
21+
# Before
22+
$ run402 secrets set prj_abc FOO bar
23+
{"status":"ok","message":"Secret 'FOO' set for project prj_abc."}
24+
25+
# After
26+
$ run402 secrets set prj_abc FOO bar
27+
{"key":"FOO","project_id":"prj_abc","set":true}
28+
```
29+
30+
- **`run402 status` and `run402 allowance status` move special statuses into typed nullable payload fields and exit 0 when absent** (was exit 1 with `status: "no_allowance"` / `status: "no_wallet"`):
31+
32+
```
33+
# Before
34+
$ run402 allowance status # exit 1
35+
{"status":"no_wallet","message":"No agent allowance found. Run: run402 allowance create"}
36+
37+
# After
38+
$ run402 allowance status # exit 0
39+
{"wallet":null,"hint":"Run: run402 allowance create"}
40+
```
41+
42+
- **What did NOT change:** stderr error envelopes (still `{ status: "error", code, message, ... }` with non-zero exit), all SDK return types, all MCP tool output shapes, per-item `status` fields inside payload objects (e.g. `run402 doctor`'s `checks[].status`).
43+
44+
### Added
45+
46+
- `cli/llms-cli.txt` now leads with an explicit "Output Contract" section documenting the stdout / stderr / exit-code shape across every subcommand.
47+
- `cli-output-contract.test.mjs` — drift-protection test that fails CI on any new top-level `JSON.stringify({ status: ... })` emission outside `cli/lib/sdk-errors.mjs`.
48+
49+
### Changed
50+
51+
- 68 success-path emit sites across 19 `cli/lib/*.mjs` files dropped their `status: "ok"` wrapper. Three `console.error(JSON.stringify({ status: "error", ... }))` sites in `cli/lib/init.mjs`, `cli/lib/projects.mjs`, and `cli/lib/sites.mjs` now route through `fail()` in `cli/lib/sdk-errors.mjs` instead of emitting the error envelope inline.
52+
- `~50` test assertions in `cli-e2e.test.mjs` migrated from `parsed.status === "ok"` to assertions on the new payload-specific fields. The two `CLI status exit codes (GH-191)` tests now assert the new exit-0 typed-null behavior for absent local state.
53+
554
## 2.4.0 — unreleased
655

756
Surfaces the v1.56 gateway verification-no-silent-fail bundle ([parent change: `verification-no-silent-fail` in run402-private](https://github.com/kychee-com/run402-private/tree/main/openspec/changes/verification-no-silent-fail)). Closes a class of UX bugs where SES auth-verdict rejections silently failed operator email verification with no signal to the operator. Additive — old clients silently ignore the new fields.

cli-e2e.test.mjs

Lines changed: 64 additions & 52 deletions
Large diffs are not rendered by default.

cli-output-contract.test.mjs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Drift-protection for the CLI stdout output contract.
2+
//
3+
// Contract (see openspec/specs/cli-output-shape/spec.md):
4+
// - Success-path stdout SHALL NOT contain a top-level `status` field.
5+
// - The stderr error envelope (in cli/lib/sdk-errors.mjs) DOES use
6+
// `status: "error"` as a sentinel; that is the allowlisted exception.
7+
// - Per-item `status` fields inside payload objects (e.g. doctor's
8+
// `checks[].status`) are NOT envelope statuses and are not matched
9+
// by this scanner (the regex only matches `JSON.stringify({ status:`).
10+
//
11+
// If you are tempted to add a new `JSON.stringify({ status: ... })` to a
12+
// CLI subcommand handler, instead emit the raw payload. If the mutation
13+
// has no natural payload, echo the affected resource identifiers plus an
14+
// explicit boolean state field (e.g. `{ key, project_id, deleted: true }`).
15+
16+
import { describe, it } from "node:test";
17+
import assert from "node:assert/strict";
18+
import { readFileSync, readdirSync } from "node:fs";
19+
import { join, dirname } from "node:path";
20+
import { fileURLToPath } from "node:url";
21+
22+
const __dirname = dirname(fileURLToPath(import.meta.url));
23+
const CLI_LIB_DIR = join(__dirname, "cli", "lib");
24+
25+
// Allowlist: file basenames whose `JSON.stringify({ status: ...` emissions
26+
// are legitimately stderr-bound error envelopes.
27+
const STDERR_ERROR_ENVELOPE_ALLOWLIST = new Set([
28+
"sdk-errors.mjs",
29+
]);
30+
31+
// Match `JSON.stringify({ status: "<literal>"` allowing whitespace and
32+
// newlines between paren, brace, and the `status` key. Multi-line tolerant.
33+
const ENVELOPE_PATTERN = /JSON\.stringify\s*\(\s*\{\s*status\s*:\s*"([^"]+)"/g;
34+
35+
describe("CLI output contract drift protection", () => {
36+
it("no cli/lib/*.mjs file emits a top-level `status` field on success paths", () => {
37+
const files = readdirSync(CLI_LIB_DIR).filter((f) => f.endsWith(".mjs") && !f.endsWith(".test.mjs"));
38+
const violations = [];
39+
40+
for (const file of files) {
41+
if (STDERR_ERROR_ENVELOPE_ALLOWLIST.has(file)) continue;
42+
const fullPath = join(CLI_LIB_DIR, file);
43+
const source = readFileSync(fullPath, "utf-8");
44+
const matches = source.matchAll(ENVELOPE_PATTERN);
45+
for (const match of matches) {
46+
const offset = match.index ?? 0;
47+
const lineNumber = source.slice(0, offset).split("\n").length;
48+
const statusValue = match[1];
49+
violations.push({ file, line: lineNumber, statusValue });
50+
}
51+
}
52+
53+
if (violations.length > 0) {
54+
const summary = violations
55+
.map((v) => ` cli/lib/${v.file}:${v.line} → JSON.stringify({ status: "${v.statusValue}" ...`)
56+
.join("\n");
57+
assert.fail(
58+
`Found ${violations.length} disallowed top-level \`status\` emission${violations.length === 1 ? "" : "s"} ` +
59+
`in CLI success paths:\n${summary}\n\n` +
60+
`The CLI stdout envelope contract (openspec/specs/cli-output-shape/spec.md) forbids a top-level ` +
61+
`\`status\` field on success-path stdout. Emit the raw payload instead. For mutations with no natural ` +
62+
`payload, echo the affected resource identifiers plus an explicit boolean state field ` +
63+
`(e.g. \`{ key, project_id, deleted: true }\`). The only allowlisted emission is the stderr error ` +
64+
`envelope in cli/lib/sdk-errors.mjs.`,
65+
);
66+
}
67+
});
68+
69+
it("the stderr error envelope in sdk-errors.mjs continues to use status: \"error\"", () => {
70+
// This is a positive assertion: sdk-errors.mjs MUST keep the
71+
// `status: "error"` sentinel on stderr so consumers can branch on it.
72+
const source = readFileSync(join(CLI_LIB_DIR, "sdk-errors.mjs"), "utf-8");
73+
assert.match(source, /status:\s*"error"/, "sdk-errors.mjs must keep the `status: \"error\"` sentinel for the stderr error envelope");
74+
});
75+
});

cli/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ run402 subdomains claim my-app # → my-app.run402.com (auto-reas
6666
```
6767

6868
`deploy-dir` hashes each file client-side and only uploads bytes the gateway doesn't already have. Re-deploying an unchanged tree returns immediately with `bytes_uploaded: 0`. Progress events stream to stderr.
69-
Release inspection commands print `{ status: "ok", release: ... }` or `{ status: "ok", diff: ... }`; use them after deploys to compare release inventory without starting another mutation. Inventories include `release_generation`, `static_manifest_sha256`, and nullable `static_manifest_metadata`; diffs include `static_assets` counters such as unchanged/changed/added/removed and CAS byte reuse. `deploy diagnose` / `deploy resolve --url` print URL-first diagnostics with `would_serve`, `diagnostic_status`, `match`, warnings, and next steps; host misses are successful diagnostic calls with `would_serve: false`. Stable-host resolve fields can include `authorization_result`, `cas_object`, `response_variant`, `allow`, `route_pattern`, `target_type`, `target_name`, and `target_file`.
69+
Release inspection commands print `{ release: ... }` or `{ diff: ... }` (raw payload, no envelope — see the "Output Contract" section in [llms-cli.txt](llms-cli.txt)); use them after deploys to compare release inventory without starting another mutation. Inventories include `release_generation`, `static_manifest_sha256`, and nullable `static_manifest_metadata`; diffs include `static_assets` counters such as unchanged/changed/added/removed and CAS byte reuse. `deploy diagnose` / `deploy resolve --url` print URL-first diagnostics with `would_serve`, `diagnostic_status`, `match`, warnings, and next steps; host misses are successful diagnostic calls with `would_serve: false`. Stable-host resolve fields can include `authorization_result`, `cas_object`, `response_variant`, `allow`, `route_pattern`, `target_type`, `target_name`, and `target_file`.
7070

7171
### GitHub Actions OIDC deploys
7272

cli/lib/ai.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ async function translate(args) {
151151

152152
try {
153153
const data = await getSdk().ai.translate(projectId, { text, to, from: from ?? undefined, context: context ?? undefined });
154-
console.log(JSON.stringify({ status: "ok", text: data.text, from: data.from, to: data.to }));
154+
console.log(JSON.stringify({ text: data.text, from: data.from, to: data.to }));
155155
} catch (err) {
156156
reportSdkError(err);
157157
}
@@ -194,7 +194,7 @@ async function moderate(args) {
194194

195195
try {
196196
const data = await getSdk().ai.moderate(projectId, text);
197-
console.log(JSON.stringify({ status: "ok", flagged: data.flagged, categories: data.categories, category_scores: data.category_scores }));
197+
console.log(JSON.stringify({ flagged: data.flagged, categories: data.categories, category_scores: data.category_scores }));
198198
} catch (err) {
199199
reportSdkError(err);
200200
}
@@ -216,7 +216,7 @@ async function usage(args) {
216216

217217
try {
218218
const data = await getSdk().ai.usage(projectId);
219-
console.log(JSON.stringify({ status: "ok", ...data }));
219+
console.log(JSON.stringify(data));
220220
} catch (err) {
221221
reportSdkError(err);
222222
}

cli/lib/allowance.mjs

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -82,23 +82,24 @@ async function status() {
8282
try {
8383
const data = await getSdk().allowance.status();
8484
if (!data.configured) {
85-
console.log(JSON.stringify({ status: "no_wallet", message: "No agent allowance found. Run: run402 allowance create" }));
86-
process.exit(1);
85+
console.log(JSON.stringify({ wallet: null, hint: "Run: run402 allowance create" }));
86+
return;
8787
}
8888
// Preserve CLI's rail field (SDK doesn't surface it; read from local allowance).
8989
const w = readAllowance();
9090
console.log(JSON.stringify({
91-
status: "ok",
92-
address: data.address,
93-
created: data.created,
94-
configured: data.configured,
95-
// GH-109: `funded` used to leak an on-disk marker that only tracks
96-
// faucet invocation, not actual pay-readiness. Renamed to `faucet_used`
97-
// to match its real semantics. For a true "can this account pay right
98-
// now" check, use `run402 allowance balance`.
99-
faucet_used: !!data.faucet_used,
100-
rail: w?.rail || "x402",
101-
path: data.path ?? ALLOWANCE_FILE,
91+
wallet: {
92+
address: data.address,
93+
created: data.created,
94+
configured: data.configured,
95+
// GH-109: `funded` used to leak an on-disk marker that only tracks
96+
// faucet invocation, not actual pay-readiness. Renamed to `faucet_used`
97+
// to match its real semantics. For a true "can this account pay right
98+
// now" check, use `run402 allowance balance`.
99+
faucet_used: !!data.faucet_used,
100+
rail: w?.rail || "x402",
101+
path: data.path ?? ALLOWANCE_FILE,
102+
},
102103
}));
103104
} catch (err) {
104105
reportSdkError(err);
@@ -109,9 +110,9 @@ async function create() {
109110
try {
110111
const result = await getSdk().allowance.create();
111112
console.log(JSON.stringify({
112-
status: "ok",
113113
address: result.address,
114-
message: `Agent allowance created. Stored locally at ${result.path ?? ALLOWANCE_FILE}`,
114+
path: result.path ?? ALLOWANCE_FILE,
115+
created: true,
115116
}));
116117
} catch (err) {
117118
const msg = (err instanceof Error) ? err.message : String(err);
@@ -199,7 +200,7 @@ async function fund() {
199200
}
200201

201202
saveAllowance({ ...w, funded: true, lastFaucet: new Date().toISOString() });
202-
console.log(JSON.stringify({ status: "ok", message: "Faucet request sent but balance not yet confirmed", ...data }));
203+
console.log(JSON.stringify({ ...data, balance_confirmed: false, hint: "Faucet request sent but on-chain balance not yet confirmed" }));
203204
}
204205

205206
async function readUsdcBalance(client, usdc, address) {

cli/lib/apps.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ async function update(projectId, versionId, args) {
276276
if (parsedArgs.includes("--no-fork")) opts.fork_allowed = false;
277277
try {
278278
await getSdk().apps.updateVersion(positionals[0], positionals[1], opts);
279-
console.log(JSON.stringify({ status: "ok", project_id: positionals[0], version_id: positionals[1] }));
279+
console.log(JSON.stringify({ project_id: positionals[0], version_id: positionals[1], updated: true }));
280280
} catch (err) {
281281
reportSdkError(err);
282282
}
@@ -294,7 +294,7 @@ async function deleteVersion(projectId, versionId, args = []) {
294294
}
295295
try {
296296
await getSdk().apps.deleteVersion(positionals[0], positionals[1]);
297-
console.log(JSON.stringify({ status: "ok", message: `Version ${positionals[1]} deleted.` }));
297+
console.log(JSON.stringify({ project_id: positionals[0], version_id: positionals[1], deleted: true }));
298298
} catch (err) {
299299
reportSdkError(err);
300300
}

cli/lib/assets.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,7 @@ async function get(projectId, argv) {
505505

506506
mkdirSync(dirname(resolvePath(opts.output)), { recursive: true });
507507
await pipeline(res.body, createWriteStream(opts.output));
508-
console.log(JSON.stringify({ status: "ok", key, output: opts.output }));
508+
console.log(JSON.stringify({ key, project_id: resolvedId, output: opts.output }));
509509
}
510510

511511
// ---------------------------------------------------------------------------
@@ -548,7 +548,7 @@ async function rm(projectId, argv) {
548548

549549
try {
550550
await getSdk().assets.rm(resolvedId, key);
551-
console.log(JSON.stringify({ status: "ok", key }));
551+
console.log(JSON.stringify({ key, project_id: resolvedId, deleted: true }));
552552
} catch (err) {
553553
reportSdkError(err);
554554
}

cli/lib/auth.mjs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ async function magicLink(args) {
351351
intent: intent ?? undefined,
352352
clientState: state ?? undefined,
353353
});
354-
console.log(JSON.stringify({ status: "ok", email, redirect_url: redirect, intent: intent || "signin" }));
354+
console.log(JSON.stringify({ email, redirect_url: redirect, intent: intent || "signin", sent: true }));
355355
} catch (err) {
356356
reportSdkError(err);
357357
}
@@ -380,7 +380,7 @@ async function createUser(args) {
380380
redirectUrl: redirectUrl ?? undefined,
381381
clientState: clientState ?? undefined,
382382
});
383-
console.log(JSON.stringify({ status: "ok", ...data }));
383+
console.log(JSON.stringify(data));
384384
} catch (err) {
385385
reportSdkError(err);
386386
}
@@ -407,7 +407,7 @@ async function inviteUser(args) {
407407
isAdmin,
408408
clientState: clientState ?? undefined,
409409
});
410-
console.log(JSON.stringify({ status: "ok", ...data }));
410+
console.log(JSON.stringify(data));
411411
} catch (err) {
412412
reportSdkError(err);
413413
}
@@ -423,7 +423,7 @@ async function verify(args) {
423423

424424
try {
425425
const data = await getSdk().auth.verifyMagicLink(projectId, token);
426-
console.log(JSON.stringify({ status: "ok", ...data }));
426+
console.log(JSON.stringify(data));
427427
} catch (err) {
428428
reportSdkError(err);
429429
}
@@ -448,7 +448,7 @@ async function setPassword(args) {
448448
newPassword,
449449
currentPassword: currentPassword ?? undefined,
450450
});
451-
console.log(JSON.stringify({ status: "ok" }));
451+
console.log(JSON.stringify({ project_id: projectId, password_set: true }));
452452
} catch (err) {
453453
reportSdkError(err);
454454
}
@@ -496,7 +496,7 @@ async function settings(args) {
496496
require_passkey_for_project_admin: requireAdminPasskey,
497497
};
498498
const data = await getSdk().auth.settings(projectId, patch);
499-
console.log(JSON.stringify({ status: "ok", ...patch, ...data }));
499+
console.log(JSON.stringify({ ...patch, ...data }));
500500
} catch (err) {
501501
reportSdkError(err);
502502
}
@@ -534,7 +534,7 @@ async function passkeyRegisterVerify(args) {
534534
response,
535535
label: label ?? undefined,
536536
});
537-
console.log(JSON.stringify({ status: "ok", ...data }));
537+
console.log(JSON.stringify(data));
538538
} catch (err) {
539539
reportSdkError(err);
540540
}
@@ -566,7 +566,7 @@ async function passkeyLoginVerify(args) {
566566
challengeId,
567567
response,
568568
});
569-
console.log(JSON.stringify({ status: "ok", ...data }));
569+
console.log(JSON.stringify(data));
570570
} catch (err) {
571571
reportSdkError(err);
572572
}
@@ -595,7 +595,7 @@ async function deletePasskey(args) {
595595
accessToken,
596596
passkeyId,
597597
});
598-
console.log(JSON.stringify({ status: "ok", passkey_id: passkeyId }));
598+
console.log(JSON.stringify({ passkey_id: passkeyId, deleted: true }));
599599
} catch (err) {
600600
reportSdkError(err);
601601
}

cli/lib/billing.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ async function autoRecharge(args) {
275275
enabled: state === "on",
276276
threshold,
277277
});
278-
console.log(JSON.stringify({ status: "ok", billing_account_id: accountId, enabled: state === "on" }));
278+
console.log(JSON.stringify({ billing_account_id: accountId, enabled: state === "on", updated: true }));
279279
} catch (err) {
280280
reportSdkError(err);
281281
}

0 commit comments

Comments
 (0)