Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-wallet-list-stdout-json.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
59 changes: 55 additions & 4 deletions src/__tests__/wallet.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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:');
});
});

Expand Down
10 changes: 9 additions & 1 deletion src/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 11 additions & 7 deletions src/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down