@getbags/cash 0.2.1 → 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/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
2
  import { checkEndpoint, createDeposit, createKey, discover, getBalance, getFund, getPolicies, getTransactions, listKeys, redeem, revokeKey, registerOrigin, search, setPolicies, listSessions, closeSessions, walletFetch, CliApiError, } from './api.js';
6
- import { getBaseUrl, getSkillSetupPrompt, getSkillUrl, requireToken, saveCredentials } from './config.js';
3
+ import { getBaseUrl, requireToken, saveCredentials } from './config.js';
4
+ import { formatDoctorSummary, runDoctor } from './doctor.js';
5
+ import { runInitCommand } from './install-command.js';
6
+ import { runStdioServer } from './mcp-server.js';
7
7
  import { parseArgs, USAGE } from './parse.js';
8
8
  function centsToDollars(cents) {
9
9
  return `$${(cents / 100).toFixed(2)}`;
@@ -12,24 +12,6 @@ function fail(message) {
12
12
  console.error(message);
13
13
  process.exit(1);
14
14
  }
15
- function skillSourcePath() {
16
- const here = dirname(fileURLToPath(import.meta.url));
17
- return join(here, '..', 'SKILL.md');
18
- }
19
- function installSkill() {
20
- const cwd = process.cwd();
21
- const candidates = [join(cwd, '.cursor', 'skills'), join(cwd, '.claude', 'skills'), join(cwd, '.agents', 'skills')];
22
- const parent = candidates.find((dir) => existsSync(dir)) ?? join(cwd, '.agents', 'skills');
23
- const destDir = join(parent, 'bags-cash');
24
- mkdirSync(destDir, { recursive: true });
25
- const dest = join(destDir, 'SKILL.md');
26
- const source = skillSourcePath();
27
- if (!existsSync(source)) {
28
- throw new Error(`bundled SKILL.md not found at ${source}`);
29
- }
30
- writeFileSync(dest, readFileSync(source, 'utf8'), 'utf8');
31
- return dest;
32
- }
33
15
  function printDiscoverTable(endpoints) {
34
16
  if (endpoints.length === 0) {
35
17
  console.log('(no endpoints)');
@@ -82,9 +64,14 @@ async function main() {
82
64
  try {
83
65
  switch (parsed.command) {
84
66
  case 'redeem': {
85
- const result = await redeem(parsed.code);
67
+ const result = await redeem({
68
+ code: parsed.code,
69
+ mode: parsed.test ? 'test' : undefined,
70
+ });
86
71
  saveCredentials(result.token);
87
- console.log(`Redeemed. Balance: ${centsToDollars(result.balance_cents)}`);
72
+ console.log(parsed.test
73
+ ? `Sandbox wallet ready. Balance: ${centsToDollars(result.balance_cents)} (test mode — Base Sepolia + Solana devnet only)`
74
+ : `Redeemed. Balance: ${centsToDollars(result.balance_cents)}`);
88
75
  console.log('Next steps:');
89
76
  console.log(' npx @getbags/cash balance');
90
77
  console.log(' npx @getbags/cash discover');
@@ -375,10 +362,41 @@ async function main() {
375
362
  return;
376
363
  }
377
364
  case 'init': {
378
- const dest = installSkill();
379
- console.log(`Wrote agent skill to ${dest}`);
380
- console.log(`Hosted skill: ${getSkillUrl()}`);
381
- console.log(`Agent prompt: ${getSkillSetupPrompt()}`);
365
+ const { exitCode } = runInitCommand({
366
+ dryRun: parsed.dryRun,
367
+ clients: parsed.clients,
368
+ scope: parsed.scope,
369
+ });
370
+ if (exitCode !== 0)
371
+ process.exit(exitCode);
372
+ return;
373
+ }
374
+ case 'mcp': {
375
+ // Stdio transport owns stdout for JSON-RPC — never write logs there.
376
+ try {
377
+ await runStdioServer();
378
+ }
379
+ catch (err) {
380
+ console.error(err instanceof Error ? err.message : String(err));
381
+ process.exit(1);
382
+ }
383
+ return;
384
+ }
385
+ case 'doctor': {
386
+ const checks = await runDoctor({
387
+ scope: parsed.scope,
388
+ requestedClients: parsed.clients.length > 0 ? parsed.clients : undefined,
389
+ });
390
+ let failed = false;
391
+ for (const check of checks) {
392
+ const mark = check.status === 'pass' ? 'pass' : check.status === 'warn' ? 'WARN' : 'FAIL';
393
+ console.log(`[${mark}] ${check.name}: ${check.message}`);
394
+ if (check.status === 'fail')
395
+ failed = true;
396
+ }
397
+ console.log(formatDoctorSummary(checks));
398
+ if (failed)
399
+ process.exit(1);
382
400
  return;
383
401
  }
384
402
  }
@@ -0,0 +1,29 @@
1
+ import type { PlannedOperation } from './install-plan.js';
2
+ export type ApplyFs = {
3
+ existsSync: (path: string) => boolean;
4
+ mkdirSync: (path: string, opts: {
5
+ recursive: boolean;
6
+ mode?: number;
7
+ }) => void;
8
+ writeFileSync: (path: string, content: string, opts: {
9
+ encoding: 'utf8';
10
+ mode?: number;
11
+ }) => void;
12
+ copyFileSync: (src: string, dest: string) => void;
13
+ renameSync: (from: string, to: string) => void;
14
+ chmodSync: (path: string, mode: number) => void;
15
+ readFileSync?: (path: string, encoding: 'utf8') => string;
16
+ };
17
+ /** Strip live wallet tokens from backup payloads (defense in depth). */
18
+ export declare function redactSecretsForBackup(content: string): string;
19
+ /**
20
+ * Execute a plan produced by `planInstall`.
21
+ * Dry-run is implemented by not calling this function — there is no parallel path.
22
+ *
23
+ * Backups go to whatever `backupPath` the planner chose (must be under ~/.bags/backups).
24
+ * Writes are atomic: temp file in the same directory, then rename into place.
25
+ */
26
+ export declare function applyInstallPlan(operations: PlannedOperation[], fs?: ApplyFs): {
27
+ written: string[];
28
+ backedUp: string[];
29
+ };
@@ -0,0 +1,55 @@
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ const defaultFs = {
4
+ existsSync,
5
+ mkdirSync: (path, opts) => {
6
+ mkdirSync(path, opts);
7
+ },
8
+ writeFileSync: (path, content, opts) => {
9
+ writeFileSync(path, content, opts);
10
+ },
11
+ copyFileSync,
12
+ renameSync,
13
+ chmodSync,
14
+ readFileSync: (path, encoding) => readFileSync(path, encoding),
15
+ };
16
+ /** Strip live wallet tokens from backup payloads (defense in depth). */
17
+ export function redactSecretsForBackup(content) {
18
+ return content.replace(/bag_agent_sk_[A-Za-z0-9_]+/g, 'bag_agent_sk_[REDACTED]');
19
+ }
20
+ function readExisting(fs, path) {
21
+ if (fs.readFileSync)
22
+ return fs.readFileSync(path, 'utf8');
23
+ // Fallback for injected fs that only implements copy: copy to a side channel is unavailable.
24
+ // Tests always provide writeFileSync-backed content via exists + prior writes.
25
+ throw new Error(`readFileSync required to backup ${path}`);
26
+ }
27
+ /**
28
+ * Execute a plan produced by `planInstall`.
29
+ * Dry-run is implemented by not calling this function — there is no parallel path.
30
+ *
31
+ * Backups go to whatever `backupPath` the planner chose (must be under ~/.bags/backups).
32
+ * Writes are atomic: temp file in the same directory, then rename into place.
33
+ */
34
+ export function applyInstallPlan(operations, fs = defaultFs) {
35
+ const written = [];
36
+ const backedUp = [];
37
+ for (const op of operations) {
38
+ const dir = dirname(op.path);
39
+ fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
40
+ if (op.backupPath && fs.existsSync(op.path)) {
41
+ const backupDir = dirname(op.backupPath);
42
+ fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
43
+ const raw = readExisting(fs, op.path);
44
+ const redacted = redactSecretsForBackup(raw);
45
+ fs.writeFileSync(op.backupPath, redacted, { encoding: 'utf8', mode: 0o600 });
46
+ fs.chmodSync(op.backupPath, 0o600);
47
+ backedUp.push(op.backupPath);
48
+ }
49
+ const tmpPath = join(dir, `.bags-init-${process.pid}-${Date.now()}.tmp-${Math.random().toString(16).slice(2)}`);
50
+ fs.writeFileSync(tmpPath, op.content, { encoding: 'utf8', mode: 0o644 });
51
+ fs.renameSync(tmpPath, op.path);
52
+ written.push(op.path);
53
+ }
54
+ return { written, backedUp };
55
+ }
@@ -0,0 +1,17 @@
1
+ import { type ClientId, type InstallScope } from './detect.js';
2
+ export type InitCommandOptions = {
3
+ dryRun: boolean;
4
+ clients: ClientId[];
5
+ scope: InstallScope;
6
+ cwd?: string;
7
+ home?: string;
8
+ /** Override stdout (tests). Defaults to console.log. */
9
+ log?: (line: string) => void;
10
+ };
11
+ /**
12
+ * Orchestrate `bags init`: detect clients → snapshot → plan → (optional) apply.
13
+ * Informational when no client is detected (does not throw).
14
+ */
15
+ export declare function runInitCommand(opts: InitCommandOptions): {
16
+ exitCode: number;
17
+ };
@@ -0,0 +1,173 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { getBaseUrl, loadCredentials } from './config.js';
6
+ import { detectClients } from './detect.js';
7
+ import { applyInstallPlan } from './install-apply.js';
8
+ import { bagsSkillPath, planInstall, } from './install-plan.js';
9
+ function skillSourcePath() {
10
+ const here = dirname(fileURLToPath(import.meta.url));
11
+ return join(here, '..', 'SKILL.md');
12
+ }
13
+ function readOptionalFile(path) {
14
+ if (!existsSync(path))
15
+ return null;
16
+ try {
17
+ return readFileSync(path, 'utf8');
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ function loadBundledSkill() {
24
+ const source = skillSourcePath();
25
+ if (!existsSync(source)) {
26
+ throw new Error(`bundled SKILL.md not found at ${source}`);
27
+ }
28
+ return readFileSync(source, 'utf8');
29
+ }
30
+ function buildSnapshot(opts) {
31
+ const env = detectClients({
32
+ cwd: opts.cwd,
33
+ home: opts.home,
34
+ scope: opts.scope,
35
+ exists: existsSync,
36
+ requestedClients: opts.requestedClients?.length ? opts.requestedClients : undefined,
37
+ });
38
+ const files = {};
39
+ for (const client of env.clients) {
40
+ files[client.mcpConfigPath] = readOptionalFile(client.mcpConfigPath);
41
+ files[bagsSkillPath(client.skillsDir)] = readOptionalFile(bagsSkillPath(client.skillsDir));
42
+ files[client.instructionsPath] = readOptionalFile(client.instructionsPath);
43
+ }
44
+ const token = loadCredentials()?.token ?? null;
45
+ return {
46
+ scope: env.scope,
47
+ cwd: opts.cwd,
48
+ home: opts.home,
49
+ clients: env.clients,
50
+ lookedFor: env.lookedFor,
51
+ files,
52
+ hasToken: Boolean(token),
53
+ skillContent: loadBundledSkill(),
54
+ targetHost: getBaseUrl(),
55
+ };
56
+ }
57
+ function summarizeOperation(op) {
58
+ const backup = op.backupPath ? ` (backup ${op.backupPath})` : ' (new)';
59
+ return ` ${op.path}${backup}`;
60
+ }
61
+ function clientLabel(id) {
62
+ switch (id) {
63
+ case 'cursor':
64
+ return 'Cursor';
65
+ case 'claude':
66
+ return 'Claude Code';
67
+ case 'codex':
68
+ return 'Codex';
69
+ }
70
+ }
71
+ function formatClientList(labels) {
72
+ if (labels.length === 0)
73
+ return 'your agent client';
74
+ if (labels.length === 1)
75
+ return labels[0];
76
+ if (labels.length === 2)
77
+ return `${labels[0]} and ${labels[1]}`;
78
+ return `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]}`;
79
+ }
80
+ function clientsWrittenTo(clients, written) {
81
+ const out = [];
82
+ for (const client of clients) {
83
+ const skillPath = bagsSkillPath(client.skillsDir);
84
+ const hit = written.some((p) => p === client.mcpConfigPath || p === skillPath || p === client.instructionsPath);
85
+ if (hit && !out.includes(client.client))
86
+ out.push(client.client);
87
+ }
88
+ return out;
89
+ }
90
+ function printNextSteps(opts) {
91
+ const { log, hasToken } = opts;
92
+ const names = formatClientList(opts.clientIds.map(clientLabel));
93
+ const steps = [];
94
+ if (!hasToken) {
95
+ steps.push('Run: bags redeem --test (or bags redeem <code>)');
96
+ }
97
+ steps.push(opts.clientIds.length > 1
98
+ ? `Restart ${names} so they pick up the bags-cash MCP server`
99
+ : `Restart ${names} so it picks up the bags-cash MCP server`);
100
+ steps.push('Run: bags doctor');
101
+ log('Next steps:');
102
+ for (let i = 0; i < steps.length; i++) {
103
+ log(` ${i + 1}. ${steps[i]}`);
104
+ }
105
+ }
106
+ function printPlanSummary(result, opts) {
107
+ const { log } = opts;
108
+ for (const line of result.diagnostics) {
109
+ log(line);
110
+ }
111
+ if (result.operations.length === 0) {
112
+ return;
113
+ }
114
+ log(opts.dryRun ? 'Dry run — would write:' : 'Will write:');
115
+ for (const op of result.operations) {
116
+ log(summarizeOperation(op));
117
+ }
118
+ }
119
+ /**
120
+ * Orchestrate `bags init`: detect clients → snapshot → plan → (optional) apply.
121
+ * Informational when no client is detected (does not throw).
122
+ */
123
+ export function runInitCommand(opts) {
124
+ const log = opts.log ?? ((line) => console.log(line));
125
+ const cwd = opts.cwd ?? process.cwd();
126
+ const home = opts.home ?? homedir();
127
+ const snapshot = buildSnapshot({
128
+ cwd,
129
+ home,
130
+ scope: opts.scope,
131
+ requestedClients: opts.clients,
132
+ });
133
+ if (snapshot.clients.length === 0) {
134
+ const plan = planInstall(snapshot);
135
+ for (const line of plan.diagnostics) {
136
+ log(line);
137
+ }
138
+ return { exitCode: 0 };
139
+ }
140
+ const plan = planInstall(snapshot);
141
+ printPlanSummary(plan, { dryRun: opts.dryRun, log });
142
+ if (plan.operations.length === 0) {
143
+ // Diagnostics already said "Already configured" — reinforce per target.
144
+ for (const client of snapshot.clients) {
145
+ log(` ${client.client}: already configured (${client.mcpConfigPath})`);
146
+ }
147
+ return { exitCode: 0 };
148
+ }
149
+ if (opts.dryRun) {
150
+ log('Dry run complete — no files written.');
151
+ return { exitCode: 0 };
152
+ }
153
+ const applied = applyInstallPlan(plan.operations);
154
+ log(`Wrote ${applied.written.length} file(s):`);
155
+ for (const path of applied.written) {
156
+ log(` ${path}`);
157
+ }
158
+ if (applied.backedUp.length > 0) {
159
+ log(`Backed up ${applied.backedUp.length} file(s):`);
160
+ for (const path of applied.backedUp) {
161
+ log(` ${path}`);
162
+ }
163
+ }
164
+ if (applied.written.length > 0) {
165
+ const clientIds = clientsWrittenTo(snapshot.clients, applied.written);
166
+ printNextSteps({
167
+ log,
168
+ hasToken: snapshot.hasToken,
169
+ clientIds: clientIds.length > 0 ? clientIds : snapshot.clients.map((c) => c.client),
170
+ });
171
+ }
172
+ return { exitCode: 0 };
173
+ }
@@ -0,0 +1,68 @@
1
+ import type { ClientId, ClientPaths, InstallScope, McpConfigFormat } from './detect.js';
2
+ export declare const BAGS_MCP_SERVER_NAME = "bags-cash";
3
+ export declare const DEFAULT_MCP_STDIO: {
4
+ readonly command: "npx";
5
+ readonly args: readonly ["-y", "@getbags/cash", "mcp"];
6
+ };
7
+ export declare const ROUTING_RULE_START = "<!-- bags-cash-init:start -->";
8
+ export declare const ROUTING_RULE_END = "<!-- bags-cash-init:end -->";
9
+ /** Full fenced routing-rule block written into agent instruction files. */
10
+ export declare const ROUTING_RULE_BLOCK: string;
11
+ export type McpStdioEntry = {
12
+ command: string;
13
+ args: string[];
14
+ };
15
+ export type PlannedOperation = {
16
+ path: string;
17
+ content: string;
18
+ /** Present when the target already exists and will be overwritten. */
19
+ backupPath?: string;
20
+ };
21
+ export type InstallPlanResult = {
22
+ operations: PlannedOperation[];
23
+ diagnostics: string[];
24
+ };
25
+ export type EnvironmentSnapshot = {
26
+ scope: InstallScope;
27
+ cwd: string;
28
+ home: string;
29
+ clients: ClientPaths[];
30
+ lookedFor: string[];
31
+ /** Current file contents keyed by absolute path; `null` means missing. */
32
+ files: Record<string, string | null | undefined>;
33
+ hasToken: boolean;
34
+ /**
35
+ * Optional loaded wallet credential (tests / future callers).
36
+ * planInstall MUST never write this into any planned file — the token stays
37
+ * in ~/.bags/credentials.json and is read at MCP tool-call time.
38
+ */
39
+ loadedCredential?: string;
40
+ skillContent: string;
41
+ targetHost: string;
42
+ /** Unix seconds for backup suffixes; defaults unused when no backups needed. */
43
+ now?: number;
44
+ requestedClients?: ClientId[];
45
+ mcpStdio?: McpStdioEntry;
46
+ };
47
+ /**
48
+ * Backups live under ~/.bags/backups/ (never next to project configs).
49
+ * Absolute path is flattened so the original location stays identifiable.
50
+ */
51
+ export declare function backupPathFor(originalPath: string, home: string, now: number): string;
52
+ /**
53
+ * Pure install planner. Returns intended file writes (and backup paths) without
54
+ * touching the filesystem. Re-running against an already-configured snapshot
55
+ * yields zero operations.
56
+ */
57
+ export declare function planInstall(snapshot: EnvironmentSnapshot): InstallPlanResult;
58
+ export type BagsMcpEntryStatus = 'missing' | 'clean' | 'dirty';
59
+ /**
60
+ * Inspect bags-cash MCP registration.
61
+ * `dirty` = entry exists but carries extra keys (env/headers/url) or wrong shape —
62
+ * secrets may still be in a committable file; doctor must FAIL and ask for re-init.
63
+ */
64
+ export declare function inspectBagsMcpEntry(content: string | null | undefined, format: McpConfigFormat, expected?: McpStdioEntry): BagsMcpEntryStatus;
65
+ /** True when a clean bags-cash stdio entry is present (no env/headers extras). */
66
+ export declare function mcpConfigHasBagsEntry(content: string | null | undefined, format: McpConfigFormat, expected?: McpStdioEntry): boolean;
67
+ export declare function instructionsHaveRoutingRule(content: string | null | undefined): boolean;
68
+ export declare function bagsSkillPath(skillsDir: string): string;