@h402/cli 0.1.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.
@@ -0,0 +1,241 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { isRecord } from "./utils.js";
7
+ const CONFIG_LOCK_OWNER_FILE = "owner.json";
8
+ const CONFIG_LOCK_GUARD_FILE = ".config.lock.guard";
9
+ const CONFIG_LOCK_WAIT_ATTEMPTS = 200;
10
+ const CONFIG_LOCK_WAIT_MS = 25;
11
+ let nativeLockLoadPromise;
12
+ function errorDescription(error) {
13
+ const message = error instanceof Error ? error.message : String(error);
14
+ const code = error?.code;
15
+ return code ? `${code}: ${message}` : message;
16
+ }
17
+ async function loadNativeLockApi() {
18
+ nativeLockLoadPromise ??= import("fs-native-extensions")
19
+ .then(({ tryLock, unlock }) => ({ api: { tryLock, unlock } }))
20
+ .catch((error) => ({ reason: errorDescription(error) }));
21
+ return nativeLockLoadPromise;
22
+ }
23
+ async function tryAcquireReclamationGuard(dir) {
24
+ const loaded = await loadNativeLockApi();
25
+ if (!loaded.api) {
26
+ return { reason: `config-lock reclamation guard is unavailable (${loaded.reason ?? "native file locking could not be loaded"})` };
27
+ }
28
+ const guardPath = path.join(dir, CONFIG_LOCK_GUARD_FILE);
29
+ let handle;
30
+ try {
31
+ handle = await open(guardPath, "a+", 0o600);
32
+ await chmod(guardPath, 0o600).catch(() => undefined);
33
+ }
34
+ catch (error) {
35
+ return { reason: `config-lock reclamation guard could not be opened (${errorDescription(error)})` };
36
+ }
37
+ let acquired = false;
38
+ try {
39
+ try {
40
+ acquired = loaded.api.tryLock(handle.fd);
41
+ }
42
+ catch (error) {
43
+ return { reason: `config-lock reclamation guard failed (${errorDescription(error)})` };
44
+ }
45
+ if (!acquired)
46
+ return {};
47
+ let released = false;
48
+ return {
49
+ release: async () => {
50
+ if (released)
51
+ return;
52
+ released = true;
53
+ try {
54
+ loaded.api?.unlock(handle.fd);
55
+ }
56
+ catch {
57
+ // Closing the descriptor releases the kernel lock even if explicit
58
+ // unlock is unsupported or fails at runtime.
59
+ }
60
+ finally {
61
+ await handle.close().catch(() => undefined);
62
+ }
63
+ }
64
+ };
65
+ }
66
+ finally {
67
+ if (!acquired)
68
+ await handle.close().catch(() => undefined);
69
+ }
70
+ }
71
+ function isConfigLockOwner(value) {
72
+ if (!isRecord(value))
73
+ return false;
74
+ const owner = value;
75
+ return (owner.version === 3 &&
76
+ Number.isSafeInteger(owner.pid) &&
77
+ owner.pid > 0 &&
78
+ typeof owner.hostname === "string" &&
79
+ owner.hostname.length > 0 &&
80
+ typeof owner.createdAt === "string" &&
81
+ Number.isFinite(Date.parse(owner.createdAt)) &&
82
+ typeof owner.token === "string" &&
83
+ owner.token.length > 0);
84
+ }
85
+ async function readConfigLockOwner(lockDir) {
86
+ let raw;
87
+ try {
88
+ raw = await readFile(path.join(lockDir, CONFIG_LOCK_OWNER_FILE), "utf8");
89
+ }
90
+ catch (error) {
91
+ const code = error.code;
92
+ if (code === "ENOENT" || code === "ENOTDIR")
93
+ return undefined;
94
+ throw error;
95
+ }
96
+ try {
97
+ const parsed = JSON.parse(raw);
98
+ return isConfigLockOwner(parsed) ? parsed : undefined;
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ }
104
+ // Signal-0 liveness: ESRCH proves no process with that PID exists now, so a
105
+ // same-host owner reporting ESRCH is definitively dead. A PID that was reused
106
+ // by another process reads as alive — that only degrades to the conservative
107
+ // manual-recovery path, never to deleting a live owner's lock. (A sibling
108
+ // container sharing this config dir AND this hostname across PID namespaces is
109
+ // outside this envelope; hostname is the machine boundary here.)
110
+ function ownerProcessAlive(pid) {
111
+ try {
112
+ process.kill(pid, 0);
113
+ return true;
114
+ }
115
+ catch (error) {
116
+ const code = error.code;
117
+ if (code === "ESRCH")
118
+ return false;
119
+ if (code === "EPERM")
120
+ return true;
121
+ return undefined;
122
+ }
123
+ }
124
+ async function inspectConfigLock(lockDir) {
125
+ try {
126
+ const owner = await readConfigLockOwner(lockDir);
127
+ if (!owner) {
128
+ const lockStat = await stat(lockDir);
129
+ const ageMs = Math.max(0, Date.now() - lockStat.mtimeMs);
130
+ return {
131
+ reclaimable: false,
132
+ reason: `lock has no valid owner metadata; automatic reclamation is unsafe (${Math.round(ageMs)}ms old)`
133
+ };
134
+ }
135
+ if (owner.hostname !== os.hostname()) {
136
+ return { token: owner.token, reclaimable: false, reason: `owner PID ${owner.pid} on ${owner.hostname} cannot be verified from this host` };
137
+ }
138
+ const alive = ownerProcessAlive(owner.pid);
139
+ if (alive === false) {
140
+ return { token: owner.token, reclaimable: true, reason: `owner PID ${owner.pid} no longer exists` };
141
+ }
142
+ if (alive === true) {
143
+ return { token: owner.token, reclaimable: false, reason: `live PID ${owner.pid} on ${owner.hostname} since ${owner.createdAt}` };
144
+ }
145
+ return { token: owner.token, reclaimable: false, reason: `owner PID ${owner.pid} could not be verified` };
146
+ }
147
+ catch (error) {
148
+ if (error.code === "ENOENT")
149
+ return { reclaimable: false, reason: "lock disappeared while being inspected" };
150
+ return { reclaimable: false, reason: `lock ownership could not be inspected: ${error.message}` };
151
+ }
152
+ }
153
+ // Reclamation is serialized by a crash-released kernel advisory lock. A stale
154
+ // observer must re-read the canonical owner while holding the guard; it never
155
+ // moves an unverified successor out of the coordination path.
156
+ async function reclaimConfigLockIfUnchanged(lockDir, observed) {
157
+ if (!observed.reclaimable || !observed.token)
158
+ return { reclaimed: false };
159
+ const guard = await tryAcquireReclamationGuard(path.dirname(lockDir));
160
+ if (!guard.release)
161
+ return { reclaimed: false, blockedReason: guard.reason };
162
+ try {
163
+ const current = await inspectConfigLock(lockDir);
164
+ if (!current.reclaimable || current.token !== observed.token)
165
+ return { reclaimed: false };
166
+ try {
167
+ await rm(lockDir, { recursive: true });
168
+ return { reclaimed: true };
169
+ }
170
+ catch (error) {
171
+ if (error.code === "ENOENT")
172
+ return { reclaimed: false };
173
+ throw error;
174
+ }
175
+ }
176
+ finally {
177
+ await guard.release();
178
+ }
179
+ }
180
+ function lockTimeoutError(lockDir, observation) {
181
+ return new Error(`Timed out waiting for h402 config lock at ${lockDir} (${observation.reason}). ` +
182
+ `If no h402 process is writing config, remove this lock path and retry; do not remove it while its owner is active.`);
183
+ }
184
+ export async function acquireConfigLock(dir) {
185
+ const lockDir = path.join(dir, ".config.lock");
186
+ const token = randomUUID();
187
+ let lastObservation = { reclaimable: false, reason: "lock is contended" };
188
+ let waitedAttempts = 0;
189
+ while (waitedAttempts < CONFIG_LOCK_WAIT_ATTEMPTS) {
190
+ try {
191
+ await mkdir(lockDir, { mode: 0o700 });
192
+ const owner = {
193
+ version: 3,
194
+ pid: process.pid,
195
+ hostname: os.hostname(),
196
+ createdAt: new Date().toISOString(),
197
+ token
198
+ };
199
+ try {
200
+ await writeFile(path.join(lockDir, CONFIG_LOCK_OWNER_FILE), JSON.stringify(owner), { mode: 0o600, flag: "wx" });
201
+ }
202
+ catch (error) {
203
+ await rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
204
+ throw error;
205
+ }
206
+ return async () => {
207
+ const currentOwner = await readConfigLockOwner(lockDir);
208
+ if (!currentOwner) {
209
+ throw new Error(`Refusing to release h402 config lock at ${lockDir}: owner metadata is missing or invalid.`);
210
+ }
211
+ if (currentOwner.token !== token) {
212
+ throw new Error(`Refusing to release h402 config lock at ${lockDir}: ownership changed to PID ${currentOwner.pid} on ${currentOwner.hostname}.`);
213
+ }
214
+ await rm(lockDir, { recursive: true, force: true });
215
+ };
216
+ }
217
+ catch (error) {
218
+ if (error.code !== "EEXIST") {
219
+ throw error;
220
+ }
221
+ lastObservation = await inspectConfigLock(lockDir);
222
+ const reclamation = await reclaimConfigLockIfUnchanged(lockDir, lastObservation);
223
+ if (reclamation.reclaimed) {
224
+ // Reclamation is not a wait attempt; retry mkdir even at the deadline.
225
+ continue;
226
+ }
227
+ if (reclamation.blockedReason) {
228
+ lastObservation = {
229
+ ...lastObservation,
230
+ reclaimable: false,
231
+ reason: `${lastObservation.reason}; ${reclamation.blockedReason}`
232
+ };
233
+ }
234
+ waitedAttempts += 1;
235
+ if (waitedAttempts < CONFIG_LOCK_WAIT_ATTEMPTS) {
236
+ await delay(CONFIG_LOCK_WAIT_MS);
237
+ }
238
+ }
239
+ }
240
+ throw lockTimeoutError(lockDir, lastObservation);
241
+ }
package/dist/config.js ADDED
@@ -0,0 +1,106 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { acquireConfigLock } from "./config-lock.js";
6
+ import { isRecord } from "./utils.js";
7
+ // @h402/cli is an end-user tool: default to the production backend. Override
8
+ // with --api-url or H402_API_URL (e.g. http://localhost:3000 for local dev).
9
+ const DEFAULT_BACKEND_URL = "https://h402.hunt.town";
10
+ function configPath() {
11
+ return path.join(os.homedir(), ".h402", "config.json");
12
+ }
13
+ function defaultConfig() {
14
+ return { backendUrl: DEFAULT_BACKEND_URL, sessions: {}, wallets: {} };
15
+ }
16
+ function normalizeConfig(parsed) {
17
+ const defaults = defaultConfig();
18
+ const normalized = {
19
+ backendUrl: typeof parsed.backendUrl === "string" ? parsed.backendUrl : defaults.backendUrl,
20
+ sessions: isRecord(parsed.sessions) ? parsed.sessions : {},
21
+ wallets: isRecord(parsed.wallets) ? parsed.wallets : {}
22
+ };
23
+ if (typeof parsed.maxUsd === "string") {
24
+ normalized.maxUsd = parsed.maxUsd;
25
+ }
26
+ return normalized;
27
+ }
28
+ async function tightenConfigPermissions(file) {
29
+ await Promise.all([chmod(path.dirname(file), 0o700).catch(() => undefined), chmod(file, 0o600).catch(() => undefined)]);
30
+ }
31
+ async function readConfigFile(file) {
32
+ let raw;
33
+ try {
34
+ raw = await readFile(file, "utf8");
35
+ await tightenConfigPermissions(file);
36
+ }
37
+ catch (error) {
38
+ if (error.code === "ENOENT") {
39
+ return undefined;
40
+ }
41
+ throw new Error(`Could not read h402 config at ${file}: ${error.message}`);
42
+ }
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(raw);
46
+ }
47
+ catch {
48
+ parsed = undefined;
49
+ }
50
+ // Surface malformed config instead of overwriting it and losing the session
51
+ // tokens and wallet mappings it holds.
52
+ if (!isRecord(parsed)) {
53
+ throw new Error(`h402 config at ${file} is not a valid config object. Fix or remove it (it holds your session tokens and known wallets).`);
54
+ }
55
+ // Normalize to the CliConfig shape so a sparse or partial file (e.g. `{}` or a
56
+ // missing/mistyped sessions/wallets key) yields a usable config instead of
57
+ // crashing later when a command reads config.sessions / config.wallets.
58
+ return normalizeConfig(parsed);
59
+ }
60
+ export async function loadConfig() {
61
+ return (await readConfigFile(configPath())) ?? defaultConfig();
62
+ }
63
+ function cloneConfig(config) {
64
+ const cloned = {
65
+ backendUrl: config.backendUrl,
66
+ sessions: { ...config.sessions },
67
+ wallets: Object.fromEntries(Object.entries(config.wallets).map(([name, wallet]) => [name, { ...wallet }]))
68
+ };
69
+ if (config.maxUsd !== undefined) {
70
+ cloned.maxUsd = config.maxUsd;
71
+ }
72
+ return cloned;
73
+ }
74
+ async function atomicWritePrivateJson(file, config) {
75
+ const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
76
+ try {
77
+ await writeFile(tmp, JSON.stringify(config, null, 2), { mode: 0o600, flag: "wx" });
78
+ await chmod(tmp, 0o600).catch(() => undefined);
79
+ await rename(tmp, file);
80
+ }
81
+ catch (error) {
82
+ await rm(tmp, { force: true }).catch(() => undefined);
83
+ throw error;
84
+ }
85
+ }
86
+ export async function updateConfig(update) {
87
+ const file = configPath();
88
+ const dir = path.dirname(file);
89
+ await mkdir(dir, { recursive: true, mode: 0o700 });
90
+ const releaseLock = await acquireConfigLock(dir);
91
+ let next;
92
+ try {
93
+ const current = (await readConfigFile(file)) ?? defaultConfig();
94
+ const draft = cloneConfig(current);
95
+ next = (await update(draft)) ?? draft;
96
+ await atomicWritePrivateJson(file, next);
97
+ }
98
+ finally {
99
+ await releaseLock();
100
+ }
101
+ await tightenConfigPermissions(file);
102
+ return next;
103
+ }
104
+ export function backendUrl(config, apiUrlFlag) {
105
+ return (apiUrlFlag ?? process.env.H402_API_URL ?? config.backendUrl ?? DEFAULT_BACKEND_URL).replace(/\/$/, "");
106
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,20 @@
1
+ // A CLI error that can carry structured `detail` (e.g. the backend's parsed JSON error
2
+ // body) so the top-level handler can surface it in the machine-readable stderr envelope.
3
+ export class CliError extends Error {
4
+ detail;
5
+ constructor(message, detail) {
6
+ super(message);
7
+ this.name = "CliError";
8
+ this.detail = detail;
9
+ }
10
+ }
11
+ // Every failure exits non-zero and writes this single shape to stderr as JSON, so an
12
+ // agent can parse one envelope for all error paths (validation, backend, empty-body,
13
+ // non-JSON): `message` is always a human-readable diagnostic; `detail` is present only
14
+ // when there is a structured backend error body to expose.
15
+ export function errorEnvelope(error) {
16
+ if (error instanceof CliError && error.detail !== undefined) {
17
+ return { error: { message: error.message, detail: error.detail } };
18
+ }
19
+ return { error: { message: error instanceof Error ? error.message : String(error) } };
20
+ }
package/dist/help.js ADDED
@@ -0,0 +1,221 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ // Reusable flag definitions (DRY: declared once, referenced by each command that
5
+ // accepts them — mirrors the README flags table).
6
+ const FLAGS = {
7
+ name: { name: "name", value: "<wallet>", desc: "Wallet to use (default h402)" },
8
+ wallet: { name: "wallet", value: "0x...", desc: "Local wallet that owns this address (must agree with --name)" },
9
+ apiUrl: { name: "api-url", value: "<url>", desc: "Backend base URL (or H402_API_URL; default https://h402.hunt.town)" },
10
+ json: { name: "json", value: "'{...}'", desc: "Request body (sets method to POST)" },
11
+ query: { name: "query", value: "'{...}'", desc: "URL query params; values must be string/number/boolean" },
12
+ provider: { name: "provider", value: "<name>", desc: "Select a concrete provider (omitted: resolve the catalog default)" },
13
+ showProvider: { name: "provider", value: "<name>", desc: "Select one concrete provider (omitted: list all enabled providers)" },
14
+ method: { name: "method", value: "GET|POST", desc: "Override the HTTP method" },
15
+ passphrase: {
16
+ name: "passphrase",
17
+ value: "[<s>]",
18
+ valueOptional: true,
19
+ desc: "Passphrase for a passphrase-protected wallet; omit the value to be prompted (or H402_WALLET_PASSPHRASE)"
20
+ },
21
+ noPassphrase: { name: "no-passphrase", desc: "Force passphrase-less signing even if H402_WALLET_PASSPHRASE is set (the default needs no flag)" },
22
+ noCredit: { name: "no-credit", desc: "Ignore bonus credits and pay x402 only" },
23
+ maxUsd: { name: "max-usd", value: "<usd>", desc: "Refuse to sign if the x402 USDC amount exceeds this cap" },
24
+ idempotencyKey: { name: "idempotency-key", value: "<uuid>", desc: "Stable key for safe retries (default: random)" },
25
+ limit: { name: "limit", value: "<n>", desc: "Max results (default 20)" }
26
+ };
27
+ export const COMMANDS = {
28
+ wallet: {
29
+ usage: "h402 wallet <create|list|restore|address|balance|fund> [flags]",
30
+ summary: "Manage local non-custodial wallets",
31
+ flags: [],
32
+ subcommands: {
33
+ create: {
34
+ usage: "h402 wallet create [flags]",
35
+ summary: "Create a local OWS signing wallet (no auth session; passphrase-less by default; prints its address)",
36
+ flags: [FLAGS.name, FLAGS.passphrase, FLAGS.noPassphrase],
37
+ examples: ["h402 wallet create --name agent"]
38
+ },
39
+ list: { usage: "h402 wallet list", summary: "List OWS wallets", flags: [] },
40
+ restore: { usage: "h402 wallet restore", summary: "Re-adopt OWS wallets into h402 config", flags: [] },
41
+ address: { usage: "h402 wallet address [flags]", summary: "Print a wallet address", flags: [FLAGS.name, FLAGS.wallet] },
42
+ balance: {
43
+ usage: "h402 wallet balance [flags]",
44
+ summary: "Show a wallet's Base USDC balance",
45
+ flags: [FLAGS.name, FLAGS.wallet],
46
+ examples: ["h402 wallet balance --name agent"]
47
+ },
48
+ fund: { usage: "h402 wallet fund [flags]", summary: "Print the Base USDC deposit address for a wallet", flags: [FLAGS.name, FLAGS.wallet] }
49
+ }
50
+ },
51
+ auth: {
52
+ usage: "h402 auth [flags]",
53
+ summary: "Create a backend bonus-credit session with a wallet signature",
54
+ flags: [FLAGS.name, FLAGS.wallet, FLAGS.apiUrl, FLAGS.passphrase, FLAGS.noPassphrase]
55
+ },
56
+ credits: { usage: "h402 credits [flags]", summary: "Show the bonus-credit balance for the signed-in session", flags: [FLAGS.apiUrl] },
57
+ search: {
58
+ usage: "h402 search <query> [flags]",
59
+ summary: "Search the catalog without a wallet (compact JSON results)",
60
+ flags: [FLAGS.apiUrl, FLAGS.limit],
61
+ examples: ['h402 search "web search"']
62
+ },
63
+ show: {
64
+ usage: "h402 show <category/action> [flags]",
65
+ summary: "Inspect a route and its full provider-native contracts",
66
+ flags: [FLAGS.apiUrl, FLAGS.showProvider],
67
+ examples: ["h402 show web/search", "h402 show web/search --provider stableenrich-exa"]
68
+ },
69
+ quote: {
70
+ usage: "h402 quote <category/action> [flags]",
71
+ summary: "Preview the x402 PAYMENT-REQUIRED envelope without paying or a wallet",
72
+ flags: [FLAGS.apiUrl, FLAGS.json, FLAGS.query, FLAGS.provider, FLAGS.method],
73
+ examples: ["h402 quote web/search --provider stableenrich-exa --json '{\"query\":\"agent APIs\"}'"]
74
+ },
75
+ call: {
76
+ usage: "h402 call <category/action> [flags]",
77
+ summary: "Execute a route and pay if challenged (free routes need no wallet)",
78
+ flags: [
79
+ FLAGS.name,
80
+ FLAGS.wallet,
81
+ FLAGS.apiUrl,
82
+ FLAGS.json,
83
+ FLAGS.query,
84
+ FLAGS.provider,
85
+ FLAGS.method,
86
+ FLAGS.passphrase,
87
+ FLAGS.noPassphrase,
88
+ FLAGS.noCredit,
89
+ FLAGS.maxUsd,
90
+ FLAGS.idempotencyKey
91
+ ],
92
+ examples: ["h402 call ai/news", "h402 call web/search --provider stableenrich-exa --name agent --json '{\"query\":\"agent APIs\"}'"]
93
+ }
94
+ };
95
+ const ENV_VARS = [
96
+ ["H402_API_URL", "Backend base URL override (or --api-url)"],
97
+ ["H402_WALLET_PASSPHRASE", "Passphrase for passphrase-protected wallets (only needed when the wallet was created with one)"]
98
+ ];
99
+ export function getVersion() {
100
+ const here = path.dirname(fileURLToPath(import.meta.url));
101
+ const manifest = JSON.parse(readFileSync(path.join(here, "..", "package.json"), "utf8"));
102
+ return manifest.version;
103
+ }
104
+ export function isKnownCommand(command) {
105
+ return Object.hasOwn(COMMANDS, command);
106
+ }
107
+ function specFor(commandPath) {
108
+ const [command, subcommand] = commandPath;
109
+ const top = command ? COMMANDS[command] : undefined;
110
+ if (!top) {
111
+ return undefined;
112
+ }
113
+ if (subcommand && top.subcommands && Object.hasOwn(top.subcommands, subcommand)) {
114
+ return top.subcommands[subcommand];
115
+ }
116
+ return top;
117
+ }
118
+ // The deepest known command/subcommand the positionals name (e.g. ["wallet",
119
+ // "balance"]); an unknown subcommand falls back to the command itself.
120
+ export function resolveCommandPath(positional) {
121
+ const [command, maybeSub] = positional;
122
+ if (!command || !isKnownCommand(command)) {
123
+ return command ? [command] : [];
124
+ }
125
+ const spec = COMMANDS[command];
126
+ if (spec.subcommands && maybeSub && Object.hasOwn(spec.subcommands, maybeSub)) {
127
+ return [command, maybeSub];
128
+ }
129
+ return [command];
130
+ }
131
+ function renderFlag(flag) {
132
+ const left = flag.value ? `--${flag.name} ${flag.value}` : `--${flag.name}`;
133
+ return ` ${left.padEnd(28)} ${flag.desc}`;
134
+ }
135
+ export function topLevelHelp() {
136
+ const lines = ["h402 — the x402 capability store", "", "Usage: h402 <command> [flags]", "", "Commands:"];
137
+ for (const [name, spec] of Object.entries(COMMANDS)) {
138
+ lines.push(` ${name.padEnd(10)} ${spec.summary}`);
139
+ }
140
+ lines.push("", "Run 'h402 <command> --help' for details, 'h402 --version' for the version.", "", "Environment:");
141
+ for (const [name, desc] of ENV_VARS) {
142
+ lines.push(` ${name.padEnd(24)} ${desc}`);
143
+ }
144
+ return lines.join("\n");
145
+ }
146
+ export function commandHelp(commandPath) {
147
+ const spec = specFor(commandPath);
148
+ if (!spec) {
149
+ return topLevelHelp();
150
+ }
151
+ const lines = [spec.summary, "", `Usage: ${spec.usage}`];
152
+ if (spec.subcommands) {
153
+ lines.push("", "Subcommands:");
154
+ for (const [name, sub] of Object.entries(spec.subcommands)) {
155
+ lines.push(` ${name.padEnd(10)} ${sub.summary}`);
156
+ }
157
+ }
158
+ lines.push("", "Flags:");
159
+ for (const flag of spec.flags) {
160
+ lines.push(renderFlag(flag));
161
+ }
162
+ lines.push(renderFlag({ name: "help", desc: "Print this help" }));
163
+ if (spec.examples?.length) {
164
+ lines.push("", "Examples:");
165
+ for (const example of spec.examples) {
166
+ lines.push(` ${example}`);
167
+ }
168
+ }
169
+ return lines.join("\n");
170
+ }
171
+ function unknownFlagsError(names, helpCommand) {
172
+ const label = names.length > 1 ? "Unknown flags" : "Unknown flag";
173
+ return new Error(`${label}: ${names.map((name) => `--${name}`).join(", ")}. Run: ${helpCommand}`);
174
+ }
175
+ // Reject flags the resolved command doesn't accept (so a typo like
176
+ // --idempotency-ky fails loudly), and validate value shape: a value flag must
177
+ // carry a value, a boolean flag must not. A bare value flag parses to boolean
178
+ // `true`, which flagString() silently treats as unset — e.g. `--idempotency-key`
179
+ // with no value would fall back to a random key, making a paid retry unsafe.
180
+ export function assertKnownFlags(commandPath, flags) {
181
+ const spec = specFor(commandPath);
182
+ if (!spec) {
183
+ return; // Unknown command/subcommand: the command handler reports it.
184
+ }
185
+ // Flag name -> value arity (--help is an always-allowed boolean). "optional"
186
+ // flags are meaningful both bare and with a value (bare --passphrase = prompt).
187
+ const valueFlags = new Map([["help", "none"]]);
188
+ for (const flag of spec.flags) {
189
+ valueFlags.set(flag.name, flag.value === undefined ? "none" : flag.valueOptional ? "optional" : "required");
190
+ }
191
+ const unknown = Object.keys(flags).filter((key) => !valueFlags.has(key));
192
+ if (unknown.length > 0) {
193
+ throw unknownFlagsError(unknown, `h402 ${commandPath.join(" ")} --help`);
194
+ }
195
+ for (const [name, provided] of Object.entries(flags)) {
196
+ const arity = valueFlags.get(name);
197
+ if (arity === "required" && (typeof provided !== "string" || provided === "")) {
198
+ throw new Error(`Flag --${name} requires a value. Run: h402 ${commandPath.join(" ")} --help`);
199
+ }
200
+ // A boolean flag that captured a following token (e.g. `--no-passphrase web/search`,
201
+ // where the parser greedily consumed the route id) is a mistake; "true" stays
202
+ // valid since flagBoolean() accepts it.
203
+ if (arity === "none" && typeof provided === "string" && provided !== "true") {
204
+ throw new Error(`Flag --${name} does not take a value (got "${provided}"). Run: h402 ${commandPath.join(" ")} --help`);
205
+ }
206
+ }
207
+ }
208
+ // Without a command, only --help and --version are valid; reject anything else
209
+ // (e.g. a typo'd --versoin) instead of silently printing help and exiting 0.
210
+ export function assertTopLevelFlags(flags) {
211
+ const stray = Object.keys(flags).filter((flag) => flag !== "help" && flag !== "version");
212
+ if (stray.length > 0) {
213
+ throw unknownFlagsError(stray, "h402 --help");
214
+ }
215
+ for (const name of ["help", "version"]) {
216
+ const provided = flags[name];
217
+ if (typeof provided === "string" && provided !== "true") {
218
+ throw new Error(`Flag --${name} does not take a value (got "${provided}"). Run: h402 --help`);
219
+ }
220
+ }
221
+ }
package/dist/index.js ADDED
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ import { authCommand, callCommand, creditsCommand, quoteCommand, searchCommand, showCommand, walletCommand } from "./commands.js";
3
+ import { errorEnvelope } from "./errors.js";
4
+ import { assertKnownFlags, assertTopLevelFlags, commandHelp, getVersion, isKnownCommand, resolveCommandPath, topLevelHelp } from "./help.js";
5
+ import { flagBoolean, parseArgs, writeStderr, writeStdout } from "./utils.js";
6
+ async function main() {
7
+ const args = parseArgs(process.argv.slice(2));
8
+ const command = args.positional[0];
9
+ if (!command) {
10
+ assertTopLevelFlags(args.flags);
11
+ if (flagBoolean(args.flags, "version")) {
12
+ await writeStdout(`${getVersion()}\n`);
13
+ return;
14
+ }
15
+ }
16
+ else if (command === "version") {
17
+ assertTopLevelFlags(args.flags);
18
+ const extra = args.positional.slice(1);
19
+ if (extra.length > 0) {
20
+ const label = extra.length === 1 ? "Unexpected positional argument" : "Unexpected positional arguments";
21
+ throw new Error(`${label}: ${extra.map((value) => JSON.stringify(value)).join(", ")}. Run: h402 --help`);
22
+ }
23
+ await writeStdout(`${getVersion()}\n`);
24
+ return;
25
+ }
26
+ if (!command || command === "help") {
27
+ if (command === "help") {
28
+ assertTopLevelFlags(args.flags);
29
+ }
30
+ await writeStdout(`${topLevelHelp()}\n`);
31
+ return;
32
+ }
33
+ if (!isKnownCommand(command)) {
34
+ throw new Error(`Unknown command: ${command}. Run: h402 --help`);
35
+ }
36
+ const commandPath = resolveCommandPath(args.positional);
37
+ // Reject typo'd/unsupported flags before doing any work (a silently ignored
38
+ // --idempotency-key on a paid call could double-charge on retry).
39
+ assertKnownFlags(commandPath, args.flags);
40
+ if (flagBoolean(args.flags, "help")) {
41
+ await writeStdout(`${commandHelp(commandPath)}\n`);
42
+ return;
43
+ }
44
+ if (command === "wallet")
45
+ return walletCommand(args);
46
+ if (command === "auth")
47
+ return authCommand(args);
48
+ if (command === "credits")
49
+ return creditsCommand(args);
50
+ if (command === "search")
51
+ return searchCommand(args);
52
+ if (command === "show")
53
+ return showCommand(args);
54
+ if (command === "quote")
55
+ return quoteCommand(args);
56
+ if (command === "call")
57
+ return callCommand(args);
58
+ throw new Error(`Unknown command: ${command}. Run: h402 --help`);
59
+ }
60
+ main()
61
+ .then(() => {
62
+ process.exitCode = 0;
63
+ })
64
+ .catch(async (error) => {
65
+ // Every failure exits non-zero with one machine-readable stderr shape:
66
+ // { "error": { "message", "detail"? } } (see errorEnvelope).
67
+ process.exitCode = 1;
68
+ await writeStderr(`${JSON.stringify(errorEnvelope(error), null, 2)}\n`);
69
+ });