diff --git a/.changeset/fix-wallet-list-stdout-json.md b/.changeset/fix-wallet-list-stdout-json.md new file mode 100644 index 00000000..de74bac6 --- /dev/null +++ b/.changeset/fix-wallet-list-stdout-json.md @@ -0,0 +1,5 @@ +--- +"nansen-cli": patch +--- + +Fix `nansen wallet list` polluting stdout with its human-readable summary, which made the output impossible for an agent to `JSON.parse`. The decorated summary (wallet names, EVM/Solana addresses, default marker) now goes to stderr, and the command returns a structured `{ wallets }` value so stdout carries only clean JSON — matching how research commands emit their data. The empty case (`No wallets found`) likewise prints its hint to stderr and still emits `{ "wallets": [] }` on stdout. diff --git a/AGENTS.md b/AGENTS.md index e625f7b6..4c4d7004 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Entry point is `src/index.js`. - **ESM only** — `import`/`export`, no TypeScript, no transpilation - **BigInt for token amounts** — never floating point - **Research commands** — return data objects, CLI layer formats via `formatOutput()` to stdout -- **Operational commands** (trade, wallet, login) — print human-readable text via `log()` to stdout, return `undefined` +- **Operational commands** (trade, wallet, login) — print human-readable text via `log()` to stdout, return `undefined`. **Exception:** `wallet list` routes its human-readable summary to stderr and returns `{ wallets }` so agents can `JSON.parse` stdout; follow this pattern for any wallet subcommand that returns queryable data. - **No interactive prompts in core** — use env vars (`NANSEN_WALLET_PASSWORD`, `NANSEN_API_KEY`) - **Actionable errors** — `"Not logged in. Run: nansen login"` not `"Authentication failed"` diff --git a/src/__tests__/wallet.test.js b/src/__tests__/wallet.test.js index d315dc88..bbad97f3 100644 --- a/src/__tests__/wallet.test.js +++ b/src/__tests__/wallet.test.js @@ -684,6 +684,7 @@ describe('Privy wallet create does not emit PASSWORD_REQUIRED', () => { describe('Wallet list/show CLI output for provider', () => { afterEach(() => { vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); it('list output shows provider for Privy wallets', async () => { @@ -700,12 +701,62 @@ describe('Wallet list/show CLI output for provider', () => { })); const { buildWalletCommands } = await import('../wallet.js'); - const output = []; - const cmds = buildWalletCommands({ log: (m) => output.push(m), exit: () => {} }); + let stderr = ''; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr += chunk; + return true; + }); + const cmds = buildWalletCommands({ log: () => {}, exit: () => {} }); await cmds.wallet(['list'], null, {}, {}); - const joined = output.join('\n'); - expect(joined).toContain('privy'); + // The provider tag lives in the human-readable summary, now on stderr. + expect(stderr).toContain('privy'); + }); +}); + +describe('wallet list stdout/stderr separation', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('emits only JSON.parse-able output to stdout and the human summary to stderr', async () => { + const walletsDir = path.join(tempDir, '.nansen', 'wallets'); + fs.mkdirSync(walletsDir, { recursive: true }); + fs.writeFileSync(path.join(walletsDir, 'config.json'), + JSON.stringify({ defaultWallet: 'main', passwordHash: null })); + fs.writeFileSync(path.join(walletsDir, 'main.json'), + JSON.stringify({ + name: 'main', provider: 'local', + evm: { address: '0xEvmAddr' }, + solana: { address: 'SolAddr' }, + createdAt: '2026-01-01T00:00:00Z', + })); + + const { runCLI } = await import('../cli.js'); + + const stdout = []; + let stderr = ''; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr += chunk; + return true; + }); + + await runCLI(['wallet', 'list'], { + output: (msg) => stdout.push(msg), + errorOutput: () => {}, + exit: () => {}, + }); + + // stdout carries a single clean JSON document an agent can parse + const joined = stdout.join('\n'); + const parsed = JSON.parse(joined); + expect(parsed.data.wallets).toHaveLength(1); + expect(parsed.data.wallets[0].name).toBe('main'); + + // the human-readable summary belongs on stderr, never stdout + expect(stderr).toContain('main'); + expect(stderr).toContain('EVM:'); + expect(joined).not.toContain('EVM:'); }); }); diff --git a/src/schema.json b/src/schema.json index 004ea6aa..25c39ad2 100644 --- a/src/schema.json +++ b/src/schema.json @@ -1903,7 +1903,15 @@ "description": "Create a new wallet" }, "list": { - "description": "List all wallets" + "description": "List all wallets", + "returns": [ + "wallets[].name", + "wallets[].evm", + "wallets[].solana", + "wallets[].isDefault", + "wallets[].provider", + "wallets[].createdAt" + ] }, "show": { "description": "Show wallet details", diff --git a/src/wallet.js b/src/wallet.js index 6f7546d5..f42affde 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -720,19 +720,23 @@ export function buildWalletCommands(deps = {}) { 'list': async () => { const result = listWallets(); + // Human-readable summary goes to stderr so stdout carries only the + // structured JSON that agents parse; the return value drives runCLI's + // stdout path (matching how research commands emit their data). if (result.wallets.length === 0) { - log('No wallets found. Create one with: nansen wallet create'); - return; + process.stderr.write('No wallets found. Create one with: nansen wallet create\n'); + return { wallets: result.wallets }; } - log(''); + process.stderr.write('\n'); for (const w of result.wallets) { const star = w.isDefault ? ' ★' : ''; const providerTag = w.provider === 'privy' ? ' (privy)' : ''; - log(` ${w.name}${star}${providerTag}`); - log(` EVM: ${w.evm}`); - log(` Solana: ${w.solana}`); - log(''); + process.stderr.write(` ${w.name}${star}${providerTag}\n`); + process.stderr.write(` EVM: ${w.evm}\n`); + process.stderr.write(` Solana: ${w.solana}\n`); + process.stderr.write('\n'); } + return { wallets: result.wallets }; }, 'show': async () => {