@getbags/cash 0.2.0 → 0.3.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/README.md CHANGED
@@ -30,19 +30,24 @@ Staging: use `https://staging.getbags.app/wallet/skill.md` (or set `BAGS_API_URL
30
30
 
31
31
  ## Commands
32
32
 
33
- | Command | Description |
34
- | --------------------------- | ----------------------------------------------------------- |
35
- | `redeem <code>` | Redeem an invite; saves token to `~/.bags/credentials.json` |
36
- | `balance` | Show balance, daily spent/cap, status |
37
- | `discover [query]` | List BAGS merchant endpoints (BAGS first) |
38
- | `search <query>` | NL search across BAGS + ecosystem index |
39
- | `check <url>` | Probe schema + exact quote **without paying** |
40
- | `fetch <url> [flags]` | Pay and proxy an x402 endpoint |
41
- | `transactions` | Spend / ledger history |
42
- | `policies get\|set` | Read or update spend policies |
43
- | `keys create\|list\|revoke` | Per-agent wallet keys |
44
- | `register <origin>` | Persist an origin into the discover/search index |
45
- | `init` | Write the `bags-cash` agent skill into this repo |
33
+ | Command | Description |
34
+ | --------------------------- | --------------------------------------------------------------- |
35
+ | `redeem <code>` | Redeem an invite; saves token to `~/.bags/credentials.json` |
36
+ | `redeem --test` | Mint a sandbox wallet (no invite; Base Sepolia + Solana devnet) |
37
+ | `balance` | Show balance, daily spent/cap, status |
38
+ | `discover [query]` | List BAGS merchant endpoints (BAGS first) |
39
+ | `search <query>` | NL search across BAGS + ecosystem index |
40
+ | `check <url>` | Probe schema + exact quote **without paying** |
41
+ | `fetch <url> [flags]` | Pay and proxy an x402 endpoint |
42
+ | `transactions` | Spend / ledger history |
43
+ | `policies get\|set` | Read or update spend policies |
44
+ | `keys create\|list\|revoke` | Per-agent wallet keys |
45
+ | `register <origin>` | Persist an origin into the discover/search index |
46
+ | `init [flags]` | Register MCP + skill + routing rule for detected clients |
47
+ | `mcp` | Serve wallet tools over stdio (JSON-RPC on stdout) |
48
+ | `doctor` | Verify token, MCP registration, skill, and routing rule |
49
+
50
+ `init` flags: `--dry-run`, `--client cursor|claude|codex` (force-create even without a marker directory; repeatable or comma-separated), `--scope project|user` (default `user` — ambient on this machine; use `--scope project` for committable repo-local config). After a successful apply, restart the agent client, then run `bags doctor`.
46
51
 
47
52
  Fetch / check flags: `-m/--method`, `-b/--body` (JSON string), `--max-price-cents` (fetch), `--format json|pretty`.
48
53
 
@@ -56,13 +61,16 @@ Fetch / check flags: `-m/--method`, `-b/--body` (JSON string), `--max-price-cent
56
61
 
57
62
  ```bash
58
63
  export BAGS_API_URL=https://staging.getbags.app # optional
59
- npx @getbags/cash redeem BAGS-XXXX-XXXX
64
+ npx @getbags/cash redeem --test # sandbox (no invite burn)
65
+ # or: npx @getbags/cash redeem BAGS-XXXX-XXXX
60
66
  npx @getbags/cash balance
61
- npx @getbags/cash search "weather forecast"
62
- npx @getbags/cash check 'https://example.com/paid'
63
- npx @getbags/cash fetch 'https://example.com/paid' -m POST -b '{"q":"hi"}'
67
+ npx @getbags/cash discover # includes $0.01 /api/v1 showcase
68
+ npx @getbags/cash check 'https://www.getbags.app/api/v1'
69
+ npx @getbags/cash fetch 'https://www.getbags.app/api/v1'
64
70
  ```
65
71
 
72
+ After `npm i -g @getbags/cash`, `bags` is an alias for `npx @getbags/cash`.
73
+
66
74
  Payment receipt prints on stderr; the upstream body prints on stdout.
67
75
 
68
76
  ## Auth
package/SKILL.md CHANGED
@@ -15,24 +15,56 @@ metadata:
15
15
  # BAGS Cash — Hosted Agent Wallet
16
16
 
17
17
  > Hosted skill URL: https://www.getbags.app/wallet/skill.md
18
+ > Human install guide: https://www.getbags.app/wallet/install
18
19
  > Onboard (humans): https://www.getbags.app/wallet/onboard
19
20
  > Staging: replace the host with `https://staging.getbags.app`.
20
21
 
21
- Call x402-protected APIs with a **hosted** BAGS wallet. Redeem an invite, discover services, **check** the exact quote + schema, then `fetch`. No local private keys — the agent holds only a token (`bag_agent_sk_*`).
22
+ Call x402-protected APIs with a **hosted** BAGS wallet. Redeem an invite (or mint a sandbox wallet), discover services, **check** the exact quote + schema, then `fetch`. No local private keys — the agent holds only a token (`bag_agent_sk_*`).
23
+
24
+ CLI examples below use `npx @getbags/cash …`. After a global install (`npm i -g @getbags/cash`), the `bags` binary is the same command.
22
25
 
23
26
  ## Why hosted (vs local-key wallets)
24
27
 
25
28
  Local plaintext / keychain wallets break in ephemeral sandboxes (Claude Cowork, CI, short-lived VMs): the key dies with the machine. BAGS keeps custody + ledger server-side. Your agent only needs the token in `~/.bags/credentials.json` (or `Authorization: Bearer`). Policies (caps, allowlists, kill switch) are enforced on the server — the agent cannot bypass them by editing a local file.
26
29
 
30
+ ## First-time setup (do in order)
31
+
32
+ 1. **Get a token** — sandbox (no invite) or live invite:
33
+
34
+ ```bash
35
+ npx @getbags/cash redeem --test
36
+ # or: npx @getbags/cash redeem <code>
37
+ ```
38
+
39
+ `redeem --test` calls `POST /api/wallet/redeem` with `{"mode":"test"}`. That mints a test wallet with fake **$100,000** promo credit, restricted to **Base Sepolia + Solana devnet**, and burns **no** invite. Use a live invite (`redeem <code>`) only when you need mainnet spend.
40
+
41
+ 2. **Register the wallet with your agent client:**
42
+
43
+ ```bash
44
+ npx @getbags/cash init
45
+ ```
46
+
47
+ Bare `init` writes **user-scope** config under `~` so the wallet is ambient on this machine (Cursor / Claude Code / Codex). Use `--scope project` to write **committable, repo-local** config (`.cursor/mcp.json`, `.mcp.json`, `AGENTS.md`, etc.) for a team. Other flags: `--dry-run`, `--client cursor|claude|codex` (force-create even without a marker directory).
48
+
49
+ 3. **Restart the agent client** (Cursor / Claude Code / Codex) so it loads the new MCP server. MCP configs are not hot-reloaded — skipping this leaves the wallet invisible.
50
+
51
+ 4. **Verify:**
52
+
53
+ ```bash
54
+ npx @getbags/cash doctor
55
+ ```
56
+
27
57
  ## Wallet
28
58
 
29
- | Task | Command |
30
- | ------------------------------ | --------------------------------- |
31
- | Redeem invite (saves token) | `npx @getbags/cash redeem <code>` |
32
- | Check balance + caps | `npx @getbags/cash balance` |
33
- | Install this skill into a repo | `npx @getbags/cash init` |
59
+ | Task | Command |
60
+ | --------------------------- | --------------------------------- |
61
+ | Sandbox wallet (no invite) | `npx @getbags/cash redeem --test` |
62
+ | Redeem invite (saves token) | `npx @getbags/cash redeem <code>` |
63
+ | One-command agent setup | `npx @getbags/cash init` |
64
+ | Verify agent visibility | `npx @getbags/cash doctor` |
65
+ | Check balance + caps | `npx @getbags/cash balance` |
34
66
 
35
- If there is no token yet, redeem first. If balance is too low, ask for another invite.
67
+ If there is no token yet, redeem (test or invite) first. If live balance is too low, ask for another invite.
36
68
 
37
69
  ## Workflow: balance → discover → check → fetch
38
70
 
@@ -47,12 +79,12 @@ npx @getbags/cash balance
47
79
  ### 2. Discover or search
48
80
 
49
81
  ```bash
50
- npx @getbags/cash discover # BAGS merchant catalog
82
+ npx @getbags/cash discover # BAGS catalog (includes $0.01 /api/v1 showcase)
51
83
  npx @getbags/cash discover weather forecast # BAGS first, then open x402 index
52
84
  npx @getbags/cash search "send physical mail" # NL search (catalog + ecosystem)
53
85
  ```
54
86
 
55
- Prefer `source: bags` results (refund-guaranteed on BAGS merchants when that guarantee is live). Then `source: bazaar`. Default table columns: `url`, `method`, `price`, `merchant`, `source`. Use `--format json` for the raw payload (includes schemas when present).
87
+ Bare `discover` lists BAGS’ own **$0.01** `GET /api/v1` showcase first — the natural first paid call for a new agent. Prefer `source: bags` results (refund-guaranteed on BAGS merchants when that guarantee is live). Then `source: bazaar`. Default table columns: `url`, `method`, `price`, `merchant`, `source`. Use `--format json` for the raw payload (includes schemas when present).
56
88
 
57
89
  ### 3. Check (exact quote, no payment)
58
90
 
@@ -73,6 +105,8 @@ npx @getbags/cash fetch '<url>' --max-price-cents 25
73
105
 
74
106
  Payment receipt (amount, tx hash, network) → stderr; upstream body → stdout (pipe-friendly).
75
107
 
108
+ **Latency:** wallet fetch often takes **~30–60 seconds** (paid ~30s; free/SIWX ~40–50s). Set agent-framework HTTP timeouts accordingly — do not treat a slow response as a hang until well past 60s.
109
+
76
110
  **Response envelope:** check top-level `status` / `code` first. `status: "success"` means the fetch completed as intended (including free/SIWX calls with `payment.paid: false`). A paid attempt that did not complete returns `status: "error"` with `PRE_PAYMENT_FAILED` or `NETWORK_NOT_ALLOWED` (nested `data.http_status` / `data.payment` keep the upstream diagnostic). Do not treat HTTP 200 alone as “paid OK” — and do not treat nested `http_status: 402` under `status: "success"` as a thing that happens anymore.
77
111
 
78
112
  ## Other commands
@@ -83,6 +117,8 @@ Payment receipt (amount, tx hash, network) → stderr; upstream body → stdout
83
117
  | `policies get` / `policies set --per-call-cap-cents=50` | Read/update spend caps |
84
118
  | `keys create\|list\|revoke` | Per-agent sub-keys |
85
119
  | `register <origin>` | Register origin into discover index |
120
+ | `mcp` | Serve wallet tools over stdio |
121
+ | `doctor` | Verify wallet visibility to agents |
86
122
 
87
123
  ## Important rules
88
124
 
@@ -91,23 +127,24 @@ Payment receipt (amount, tx hash, network) → stderr; upstream body → stdout
91
127
  - **Prefer `source: bags`**, then bazaar, then any trusted x402 URL via `fetch`.
92
128
  - **Failed pre-payment requests cost nothing.** If payment settled and upstream still errored, read the stderr receipt / `transactions`.
93
129
  - **Do not invent endpoints.** Use `discover` / `search` / `check` or a URL the user provided.
130
+ - **Sandbox first.** Prefer `redeem --test` while integrating; switch to a live invite for mainnet.
94
131
 
95
132
  ## Troubleshooting
96
133
 
97
- | Code | Fix |
98
- | ----------------------------------- | ----------------------------------------------------------------------------- |
99
- | `INSUFFICIENT_BALANCE` | `bags balance` then redeem another invite |
100
- | `INVITE_INVALID` / `INVITE_EXPIRED` | Ask for a new invite code |
101
- | `URL_BLOCKED` | Use a public `https` endpoint (SSRF gate) |
102
- | `CAP_EXCEEDED` | Wait, lower `--max-price-cents`, or `policies set` |
103
- | `WALLET_SUSPENDED` | Dashboard / support |
104
- | `WALLET_FETCH_DISABLED` | Retry later (global kill switch) |
105
- | `VALIDATION_FAILED` | Fix url/method/body JSON |
106
- | `NOT_FOUND` | Route not live on this env — set `BAGS_API_URL` or upgrade |
107
- | `UPSTREAM_ERROR` / `TIMEOUT` | Retry; `bags check <url>` for a fresh quote |
108
- | `NETWORK_NOT_ALLOWED` | Test wallet vs mainnet (or vice versa) — use matching mode |
109
- | `PRE_PAYMENT_FAILED` | Payment did not broadcast; reservation reversed — fix quote / network / funds |
110
- | `AUTH_MISSING_CREDENTIALS` | `bags redeem <code>` |
134
+ | Code | Fix |
135
+ | ----------------------------------- | -------------------------------------------------------------------------------------- |
136
+ | `INSUFFICIENT_BALANCE` | `npx @getbags/cash balance` then redeem another invite (or use `redeem --test` to try) |
137
+ | `INVITE_INVALID` / `INVITE_EXPIRED` | Ask for a new invite code |
138
+ | `URL_BLOCKED` | Use a public `https` endpoint (SSRF gate) |
139
+ | `CAP_EXCEEDED` | Wait, lower `--max-price-cents`, or `policies set` |
140
+ | `WALLET_SUSPENDED` | Dashboard / support |
141
+ | `WALLET_FETCH_DISABLED` | Retry later (global kill switch) |
142
+ | `VALIDATION_FAILED` | Fix url/method/body JSON |
143
+ | `NOT_FOUND` | Route not live on this env — set `BAGS_API_URL` or upgrade |
144
+ | `UPSTREAM_ERROR` / `TIMEOUT` | Retry; `npx @getbags/cash check <url>` for a fresh quote (fetch can take 30–60s) |
145
+ | `NETWORK_NOT_ALLOWED` | Test wallet vs mainnet (or vice versa) — use matching mode |
146
+ | `PRE_PAYMENT_FAILED` | Payment did not broadcast; reservation reversed — fix quote / network / funds |
147
+ | `AUTH_MISSING_CREDENTIALS` | `npx @getbags/cash redeem --test` or `npx @getbags/cash redeem <code>` |
111
148
 
112
149
  Env: `BAGS_API_URL` (default `https://www.getbags.app`; staging example `https://staging.getbags.app`).
113
150
  Token path: `~/.bags/credentials.json`.
package/dist/api.d.ts CHANGED
@@ -104,7 +104,10 @@ export declare function apiRequest(path: string, options?: {
104
104
  body?: unknown;
105
105
  token?: string;
106
106
  }): Promise<Record<string, unknown>>;
107
- export declare function redeem(code: string): Promise<RedeemResponse>;
107
+ export declare function redeem(opts: {
108
+ code?: string;
109
+ mode?: 'live' | 'test';
110
+ }): Promise<RedeemResponse>;
108
111
  export declare function getBalance(token: string): Promise<BalanceResponse>;
109
112
  export declare function discover(token: string, query?: string): Promise<DiscoverResponse>;
110
113
  export declare function search(token: string, query: string): Promise<DiscoverResponse>;
package/dist/api.js CHANGED
@@ -54,10 +54,13 @@ export async function apiRequest(path, options = {}) {
54
54
  }
55
55
  return json;
56
56
  }
57
- export async function redeem(code) {
57
+ export async function redeem(opts) {
58
+ const payload = opts.mode === 'test' && !opts.code
59
+ ? { mode: 'test' }
60
+ : { code: opts.code, ...(opts.mode ? { mode: opts.mode } : {}) };
58
61
  const body = await apiRequest('/api/wallet/redeem', {
59
62
  method: 'POST',
60
- body: { code },
63
+ body: payload,
61
64
  });
62
65
  const data = unwrapPayload(body);
63
66
  if (typeof data.token !== 'string') {
@@ -0,0 +1,33 @@
1
+ export type ClientId = 'cursor' | 'claude' | 'codex';
2
+ export type InstallScope = 'project' | 'user';
3
+ export type McpConfigFormat = 'json' | 'claude-json' | 'toml';
4
+ export type ClientPaths = {
5
+ client: ClientId;
6
+ mcpConfigPath: string;
7
+ mcpConfigFormat: McpConfigFormat;
8
+ /** Parent skills directory; skill file lives at `${skillsDir}/bags-cash/SKILL.md`. */
9
+ skillsDir: string;
10
+ instructionsPath: string;
11
+ };
12
+ export type DetectedEnvironment = {
13
+ scope: InstallScope;
14
+ clients: ClientPaths[];
15
+ /** Absolute marker paths that were probed (for no-client diagnostics). */
16
+ lookedFor: string[];
17
+ };
18
+ export type FsExists = (path: string) => boolean;
19
+ export type DetectOptions = {
20
+ cwd: string;
21
+ home: string;
22
+ scope: InstallScope;
23
+ exists: FsExists;
24
+ requestedClients?: ClientId[];
25
+ };
26
+ /**
27
+ * Detect every supported agent client whose marker path exists.
28
+ * Detect-all (not first-match-wins). Pure: filesystem access is injected.
29
+ *
30
+ * Explicit `requestedClients` (`--client`) is user intent: include those clients
31
+ * even when their marker directory is missing, so init can force-create config.
32
+ */
33
+ export declare function detectClients(opts: DetectOptions): DetectedEnvironment;
package/dist/detect.js ADDED
@@ -0,0 +1,105 @@
1
+ import { join } from 'node:path';
2
+ const ALL_CLIENTS = ['cursor', 'claude', 'codex'];
3
+ function projectPaths(cwd, client) {
4
+ switch (client) {
5
+ case 'cursor':
6
+ return {
7
+ client,
8
+ mcpConfigPath: join(cwd, '.cursor', 'mcp.json'),
9
+ mcpConfigFormat: 'json',
10
+ skillsDir: join(cwd, '.cursor', 'skills'),
11
+ instructionsPath: join(cwd, 'AGENTS.md'),
12
+ };
13
+ case 'claude':
14
+ return {
15
+ client,
16
+ mcpConfigPath: join(cwd, '.mcp.json'),
17
+ mcpConfigFormat: 'json',
18
+ skillsDir: join(cwd, '.claude', 'skills'),
19
+ instructionsPath: join(cwd, 'CLAUDE.md'),
20
+ };
21
+ case 'codex':
22
+ return {
23
+ client,
24
+ mcpConfigPath: join(cwd, '.codex', 'config.toml'),
25
+ mcpConfigFormat: 'toml',
26
+ skillsDir: join(cwd, '.agents', 'skills'),
27
+ instructionsPath: join(cwd, 'AGENTS.md'),
28
+ };
29
+ }
30
+ }
31
+ function userPaths(home, client) {
32
+ switch (client) {
33
+ case 'cursor':
34
+ return {
35
+ client,
36
+ mcpConfigPath: join(home, '.cursor', 'mcp.json'),
37
+ mcpConfigFormat: 'json',
38
+ skillsDir: join(home, '.cursor', 'skills'),
39
+ // Assumed: Cursor has no documented global AGENTS.md; we use this path
40
+ // so user-scope installs still get a fenced routing rule on disk.
41
+ instructionsPath: join(home, '.cursor', 'AGENTS.md'),
42
+ };
43
+ case 'claude':
44
+ return {
45
+ client,
46
+ mcpConfigPath: join(home, '.claude.json'),
47
+ mcpConfigFormat: 'claude-json',
48
+ skillsDir: join(home, '.claude', 'skills'),
49
+ instructionsPath: join(home, '.claude', 'CLAUDE.md'),
50
+ };
51
+ case 'codex':
52
+ return {
53
+ client,
54
+ mcpConfigPath: join(home, '.codex', 'config.toml'),
55
+ mcpConfigFormat: 'toml',
56
+ skillsDir: join(home, '.agents', 'skills'),
57
+ instructionsPath: join(home, '.codex', 'AGENTS.md'),
58
+ };
59
+ }
60
+ }
61
+ function markerPaths(cwd, home, scope, client) {
62
+ if (scope === 'project') {
63
+ switch (client) {
64
+ case 'cursor':
65
+ return [join(cwd, '.cursor')];
66
+ case 'claude':
67
+ return [join(cwd, '.claude')];
68
+ case 'codex':
69
+ return [join(cwd, '.codex')];
70
+ }
71
+ }
72
+ switch (client) {
73
+ case 'cursor':
74
+ return [join(home, '.cursor')];
75
+ case 'claude':
76
+ // Prefer the config dir; also accept the standalone user config file.
77
+ return [join(home, '.claude'), join(home, '.claude.json')];
78
+ case 'codex':
79
+ return [join(home, '.codex')];
80
+ }
81
+ }
82
+ /**
83
+ * Detect every supported agent client whose marker path exists.
84
+ * Detect-all (not first-match-wins). Pure: filesystem access is injected.
85
+ *
86
+ * Explicit `requestedClients` (`--client`) is user intent: include those clients
87
+ * even when their marker directory is missing, so init can force-create config.
88
+ */
89
+ export function detectClients(opts) {
90
+ const forced = Boolean(opts.requestedClients?.length);
91
+ const wanted = forced ? ALL_CLIENTS.filter((id) => opts.requestedClients.includes(id)) : ALL_CLIENTS;
92
+ const lookedFor = [];
93
+ const clients = [];
94
+ for (const client of wanted) {
95
+ const markers = markerPaths(opts.cwd, opts.home, opts.scope, client);
96
+ for (const marker of markers) {
97
+ if (!lookedFor.includes(marker))
98
+ lookedFor.push(marker);
99
+ }
100
+ if (forced || markers.some((m) => opts.exists(m))) {
101
+ clients.push(opts.scope === 'project' ? projectPaths(opts.cwd, client) : userPaths(opts.home, client));
102
+ }
103
+ }
104
+ return { scope: opts.scope, clients, lookedFor };
105
+ }
@@ -0,0 +1,47 @@
1
+ import { type BalanceResponse } from './api.js';
2
+ import { type ClientId, type ClientPaths, type DetectedEnvironment, type InstallScope } from './detect.js';
3
+ export type DoctorStatus = 'pass' | 'fail' | 'warn';
4
+ export type DoctorCheck = {
5
+ name: string;
6
+ status: DoctorStatus;
7
+ message: string;
8
+ };
9
+ export type DoctorFs = {
10
+ existsSync: (path: string) => boolean;
11
+ readFileSync: (path: string, encoding: 'utf8') => string;
12
+ };
13
+ export type DoctorDeps = {
14
+ cwd?: string;
15
+ home?: string;
16
+ /** When set, check only this scope. When omitted, check both project and user. */
17
+ scope?: InstallScope;
18
+ requestedClients?: ClientId[];
19
+ fs?: DoctorFs;
20
+ loadToken?: () => string | null;
21
+ fetchBalance?: (token: string) => Promise<BalanceResponse>;
22
+ detect?: (opts: {
23
+ cwd: string;
24
+ home: string;
25
+ scope: InstallScope;
26
+ exists: (path: string) => boolean;
27
+ requestedClients?: ClientId[];
28
+ }) => DetectedEnvironment;
29
+ instructionsConstant?: string;
30
+ };
31
+ export declare function checkTokenPresent(token: string | null): DoctorCheck;
32
+ export declare function checkBalanceReadable(token: string | null, fetchBalance: (token: string) => Promise<BalanceResponse>): Promise<DoctorCheck>;
33
+ export declare function checkMcpEntry(client: ClientPaths, scope: InstallScope, fs: DoctorFs): DoctorCheck;
34
+ export declare function checkSkillInstalled(client: ClientPaths, scope: InstallScope, fs: DoctorFs): DoctorCheck;
35
+ export declare function checkRoutingRule(client: ClientPaths, scope: InstallScope, fs: DoctorFs): DoctorCheck;
36
+ export declare function checkInstructionsConstant(text?: string): DoctorCheck;
37
+ /**
38
+ * One-line summary distinguishing agent visibility from wallet funding.
39
+ * Doctor's entire purpose is this distinction.
40
+ */
41
+ export declare function formatDoctorSummary(checks: DoctorCheck[]): string;
42
+ /**
43
+ * Run all doctor checks. Distinguishes "installed but not visible to agents"
44
+ * (MCP/skill/routing fails) from "visible but unfunded" (token/balance fails).
45
+ * By default checks both project and user scopes.
46
+ */
47
+ export declare function runDoctor(deps?: DoctorDeps): Promise<DoctorCheck[]>;
package/dist/doctor.js ADDED
@@ -0,0 +1,212 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { getBalance } from './api.js';
4
+ import { loadCredentials } from './config.js';
5
+ import { detectClients, } from './detect.js';
6
+ import { WALLET_MCP_INSTRUCTIONS } from './mcp-instructions.js';
7
+ import { bagsSkillPath, inspectBagsMcpEntry, instructionsHaveRoutingRule } from './install-plan.js';
8
+ const defaultFs = {
9
+ existsSync,
10
+ readFileSync: (path, encoding) => readFileSync(path, encoding),
11
+ };
12
+ function readText(fs, path) {
13
+ if (!fs.existsSync(path))
14
+ return null;
15
+ try {
16
+ return fs.readFileSync(path, 'utf8');
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ function checkName(kind, client, scope) {
23
+ return `${kind}:${client}:${scope}`;
24
+ }
25
+ export function checkTokenPresent(token) {
26
+ if (token && token.length > 0) {
27
+ return { name: 'token', status: 'pass', message: 'wallet token present in credentials store' };
28
+ }
29
+ return {
30
+ name: 'token',
31
+ status: 'fail',
32
+ message: 'no wallet token; run: bags redeem --test (or bags redeem <code>)',
33
+ };
34
+ }
35
+ export async function checkBalanceReadable(token, fetchBalance) {
36
+ if (!token) {
37
+ return {
38
+ name: 'balance',
39
+ status: 'fail',
40
+ message: 'skipped — no token (cannot read balance/caps)',
41
+ };
42
+ }
43
+ try {
44
+ const bal = await fetchBalance(token);
45
+ if (bal.balance_cents <= 0) {
46
+ return {
47
+ name: 'balance',
48
+ status: 'warn',
49
+ message: `balance 0¢ (unfunded), per-call cap ${bal.per_call_cap_cents}¢, daily cap ${bal.daily_cap_cents}¢, status ${bal.status} — redeem another invite or run: bags redeem --test`,
50
+ };
51
+ }
52
+ return {
53
+ name: 'balance',
54
+ status: 'pass',
55
+ message: `balance ${bal.balance_cents}¢, per-call cap ${bal.per_call_cap_cents}¢, daily cap ${bal.daily_cap_cents}¢, status ${bal.status}`,
56
+ };
57
+ }
58
+ catch (err) {
59
+ return {
60
+ name: 'balance',
61
+ status: 'fail',
62
+ message: err instanceof Error ? err.message : String(err),
63
+ };
64
+ }
65
+ }
66
+ export function checkMcpEntry(client, scope, fs) {
67
+ const content = readText(fs, client.mcpConfigPath);
68
+ const name = checkName('mcp', client.client, scope);
69
+ const status = inspectBagsMcpEntry(content, client.mcpConfigFormat);
70
+ if (status === 'clean') {
71
+ return {
72
+ name,
73
+ status: 'pass',
74
+ message: `bags-cash MCP entry present in ${client.mcpConfigPath}`,
75
+ };
76
+ }
77
+ if (status === 'dirty') {
78
+ return {
79
+ name,
80
+ status: 'fail',
81
+ message: `bags-cash MCP entry in ${client.mcpConfigPath} has env/headers/extra keys (possible secret) — re-run bags init to strip them`,
82
+ };
83
+ }
84
+ return {
85
+ name,
86
+ status: 'fail',
87
+ message: `bags-cash MCP entry missing in ${client.mcpConfigPath}`,
88
+ };
89
+ }
90
+ export function checkSkillInstalled(client, scope, fs) {
91
+ const path = bagsSkillPath(client.skillsDir);
92
+ const name = checkName('skill', client.client, scope);
93
+ if (fs.existsSync(path)) {
94
+ return {
95
+ name,
96
+ status: 'pass',
97
+ message: `skill present at ${path}`,
98
+ };
99
+ }
100
+ return {
101
+ name,
102
+ status: 'fail',
103
+ message: `skill missing at ${path}`,
104
+ };
105
+ }
106
+ export function checkRoutingRule(client, scope, fs) {
107
+ const content = readText(fs, client.instructionsPath);
108
+ const name = checkName('routing', client.client, scope);
109
+ if (instructionsHaveRoutingRule(content)) {
110
+ return {
111
+ name,
112
+ status: 'pass',
113
+ message: `routing-rule block present in ${client.instructionsPath}`,
114
+ };
115
+ }
116
+ return {
117
+ name,
118
+ status: 'fail',
119
+ message: `routing-rule block missing in ${client.instructionsPath}`,
120
+ };
121
+ }
122
+ export function checkInstructionsConstant(text = WALLET_MCP_INSTRUCTIONS) {
123
+ if (typeof text === 'string' && text.trim().length > 0) {
124
+ return {
125
+ name: 'instructions',
126
+ status: 'pass',
127
+ message: 'WALLET_MCP_INSTRUCTIONS constant is non-empty',
128
+ };
129
+ }
130
+ return {
131
+ name: 'instructions',
132
+ status: 'fail',
133
+ message: 'WALLET_MCP_INSTRUCTIONS constant is empty — MCP auto-pick will not work',
134
+ };
135
+ }
136
+ /**
137
+ * One-line summary distinguishing agent visibility from wallet funding.
138
+ * Doctor's entire purpose is this distinction.
139
+ */
140
+ export function formatDoctorSummary(checks) {
141
+ const visibility = checks.filter((c) => /^(mcp|skill|routing):/.test(c.name));
142
+ const visible = visibility.length > 0 && visibility.some((c) => c.status === 'pass');
143
+ const visibilityOk = visibility.length > 0 && visibility.every((c) => c.status === 'pass');
144
+ const balance = checks.find((c) => c.name === 'balance');
145
+ const token = checks.find((c) => c.name === 'token');
146
+ const funded = balance?.status === 'pass' && token?.status === 'pass';
147
+ if (visibilityOk && funded) {
148
+ return 'Summary: wallet is visible to agents and funded.';
149
+ }
150
+ if (visible && !funded) {
151
+ return 'Summary: wallet is visible to agents but unfunded (or token/balance check failed) — visibility ≠ funding.';
152
+ }
153
+ if (!visible && funded) {
154
+ return 'Summary: wallet is funded but not visible to agents — run bags init (try --scope user).';
155
+ }
156
+ if (visibility.length === 0) {
157
+ return 'Summary: no agent client detected — install Cursor/Claude/Codex or pass --client to force-create, then bags init.';
158
+ }
159
+ return 'Summary: wallet is not fully visible to agents and funding is not confirmed — fix FAIL/WARN checks above.';
160
+ }
161
+ /**
162
+ * Run all doctor checks. Distinguishes "installed but not visible to agents"
163
+ * (MCP/skill/routing fails) from "visible but unfunded" (token/balance fails).
164
+ * By default checks both project and user scopes.
165
+ */
166
+ export async function runDoctor(deps = {}) {
167
+ const cwd = deps.cwd ?? process.cwd();
168
+ const home = deps.home ?? homedir();
169
+ const scopes = deps.scope ? [deps.scope] : ['project', 'user'];
170
+ const fs = deps.fs ?? defaultFs;
171
+ const detect = deps.detect ?? detectClients;
172
+ const loadToken = deps.loadToken ?? (() => loadCredentials()?.token ?? null);
173
+ const fetchBalance = deps.fetchBalance ?? ((token) => getBalance(token));
174
+ const instructions = deps.instructionsConstant ?? WALLET_MCP_INSTRUCTIONS;
175
+ const token = loadToken();
176
+ const checks = [
177
+ checkTokenPresent(token),
178
+ await checkBalanceReadable(token, fetchBalance),
179
+ checkInstructionsConstant(instructions),
180
+ ];
181
+ let anyClient = false;
182
+ const lookedForAll = [];
183
+ for (const scope of scopes) {
184
+ const env = detect({
185
+ cwd,
186
+ home,
187
+ scope,
188
+ exists: (p) => fs.existsSync(p),
189
+ requestedClients: deps.requestedClients,
190
+ });
191
+ for (const p of env.lookedFor) {
192
+ if (!lookedForAll.includes(p))
193
+ lookedForAll.push(p);
194
+ }
195
+ if (env.clients.length === 0)
196
+ continue;
197
+ anyClient = true;
198
+ for (const client of env.clients) {
199
+ checks.push(checkMcpEntry(client, scope, fs));
200
+ checks.push(checkSkillInstalled(client, scope, fs));
201
+ checks.push(checkRoutingRule(client, scope, fs));
202
+ }
203
+ }
204
+ if (!anyClient) {
205
+ checks.push({
206
+ name: 'clients',
207
+ status: 'fail',
208
+ message: `no agent client detected (looked for: ${lookedForAll.join(', ') || 'none'})`,
209
+ });
210
+ }
211
+ return checks;
212
+ }
package/dist/errors.js CHANGED
@@ -1,9 +1,13 @@
1
1
  import { walletDepositsVisible } from './deposits-visible.js';
2
2
  export const ERROR_REMEDIES = {
3
3
  // Deposit advertising gated (SHIP-PRD T7); fund command still works when known.
4
+ // The wallet reserves up to the per-call cap before the real price is known, so
5
+ // this fires even when the balance comfortably covers the quote — a $0.45 wallet
6
+ // is refused a 1c call under a 50c cap. Lowering the ceiling shrinks the
7
+ // reservation, so offer that before telling anyone to top up.
4
8
  INSUFFICIENT_BALANCE: walletDepositsVisible()
5
- ? 'balance too low; run: bags fund (or redeem another invite code)'
6
- : 'balance too low; redeem another invite code',
9
+ ? 'balance is below the amount reserved for this call (up to the per-call cap, not the quoted price); retry with a lower --max-price-cents, run: bags fund, or redeem another invite code'
10
+ : 'balance is below the amount reserved for this call (up to the per-call cap, not the quoted price); retry with a lower --max-price-cents, or redeem another invite code',
7
11
  INVITE_INVALID: 'invite code is invalid or already used; ask your host for a new one',
8
12
  INVITE_EXPIRED: 'invite code has expired; ask your host for a new one',
9
13
  URL_BLOCKED: 'URL blocked by SSRF protection; use a public https endpoint',