Skip to content

Commit ba3c869

Browse files
sirtimidclaude
andauthored
feat(wallet-cli): add mm daemon list for callable-surface discovery (#9339)
## What `mm daemon call <Controller>:<method>` can dispatch **any** registered messenger action, but a consumer had no way to see what's callable — and the surface grows silently as controllers are wired. This adds `mm daemon list` to enumerate it. - **`listActions` RPC handler** in the daemon, backed by the live messenger's `getRegisteredActionTypes()` (landed in #9271), so the list can never drift from what `call` actually accepts — no hand-kept catalog to rot. - **`mm daemon list` command** renders an indented, counted list on a TTY, and a bare, sorted, newline-delimited list when piped (so it pipes cleanly into `grep`/`fzf`). - **README** usage section refreshed: documents `list`, frames the surface as evolving (not a stability contract), links out to each controller's TypeDoc/README for exhaustive detail, and drops the stale `@deprecated AccountsController:listAccounts` example (fixed to `KeyringController:getState`). ## Testing - New `list.test.ts` and a `listActions` handler test in `daemon-entry.test.ts`; package stays at 100% coverage. - `build`, `test`, `lint`, and `changelog:validate` all pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > CLI-only discovery and error-message refactors; no changes to wallet auth, key handling, or messenger dispatch semantics beyond a read-only introspection RPC. > > **Overview** > Adds **`mm daemon list`** so users can see which messenger actions the running wallet daemon accepts via `daemon call`, without maintaining a static catalog. > > The daemon exposes a new **`listActions`** JSON-RPC handler that returns `messenger.getRegisteredActionTypes()`, keeping discovery aligned with what `call` can dispatch. The CLI sorts actions lexicographically: on a TTY it prints a counted, indented list with usage hints; when piped it emits a bare newline-delimited list for `grep`/`fzf`. > > **`mm daemon call`** now shares centralized socket and JSON-RPC error handling via `makeDaemonConnectionError`, `formatJsonRpcError`, and `isStringArray` in `daemon/utils`, including clearer messages for `ECONNRESET` and permission errors. README and changelog document `list` and refresh `call` examples. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e9a0545. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7d5e4cc commit ba3c869

9 files changed

Lines changed: 460 additions & 15 deletions

File tree

packages/wallet-cli/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Add the `mm daemon list` command, which prints the messenger actions the running daemon can dispatch via `daemon call`, enumerated from the live messenger so the list cannot drift from what `call` accepts ([#9339](https://github.com/MetaMask/core/pull/9339))
1213
- Add the `mm daemon` command suite (`start`, `stop`, `status`, `purge`, and `call`) for running the wallet daemon and dispatching messenger actions over its socket ([#9255](https://github.com/MetaMask/core/pull/9255))
1314
- Add a wallet factory and daemon entry point that construct a `@metamask/wallet` `Wallet` backed by the SQLite key-value store, hydrate it from persisted state, run controller initialization (aborting startup if any step fails), import the secret recovery phrase on first run, and expose a `dispose` teardown handle ([#9226](https://github.com/MetaMask/core/pull/9226))
1415
- Add a daemon transport layer: a JSON-RPC client and server over a Unix socket, plus daemon spawn/stop lifecycle helpers ([#9108](https://github.com/MetaMask/core/pull/9108))
@@ -17,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1718

1819
### Changed
1920

21+
- Report daemon socket connection errors consistently across `mm daemon call` and `mm daemon list` ([#9339](https://github.com/MetaMask/core/pull/9339))
2022
- Bump `@metamask/wallet` from `^3.0.0` to `^6.0.0` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9263](https://github.com/MetaMask/core/pull/9263), [#9349](https://github.com/MetaMask/core/pull/9349))
2123

2224
[Unreleased]: https://github.com/MetaMask/core/

packages/wallet-cli/README.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,21 @@ Start the daemon (flags may also be supplied as the `INFURA_PROJECT_ID`, `MM_WAL
2020
mm daemon start --infura-project-id <key> --password <pw> --srp "<phrase>"
2121
```
2222

23+
Discover what the running wallet can do — `list` prints every messenger action currently dispatchable via `call`. This surface grows as more controllers are wired, so treat it as evolving rather than a stability contract:
24+
25+
```sh
26+
mm daemon list
27+
```
28+
2329
Call any messenger action on the running wallet (positional JSON array for arguments, optional `--timeout`):
2430

2531
```sh
26-
mm daemon call AccountsController:listAccounts
27-
mm daemon call KeyringController:getState --timeout 10000
32+
mm daemon call KeyringController:getState
33+
mm daemon call NetworkController:getState --timeout 10000
2834
```
2935

36+
For the exact parameters and return shape of a given action, see the TypeDoc/README of the controller that owns it (e.g. [`@metamask/keyring-controller`](https://github.com/MetaMask/core/tree/main/packages/keyring-controller#readme)).
37+
3038
Inspect or tear it down:
3139

3240
```sh

packages/wallet-cli/src/commands/daemon/call.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { Args, Command, Flags } from '@oclif/core';
44

55
import { sendCommand } from '../../daemon/daemon-client';
66
import { getDaemonPaths } from '../../daemon/paths';
7-
import { isErrorWithCode } from '../../daemon/utils';
7+
import {
8+
formatJsonRpcError,
9+
makeDaemonConnectionError,
10+
} from '../../daemon/utils';
811

912
export default class DaemonCall extends Command {
1013
static override description = 'Call a messenger action on the wallet daemon';
@@ -18,7 +21,7 @@ export default class DaemonCall extends Command {
1821
static override args = {
1922
action: Args.string({
2023
description:
21-
'The messenger action name (e.g. AccountsController:listAccounts)',
24+
'The messenger action name (e.g. KeyringController:getState)',
2225
required: true,
2326
}),
2427
params: Args.string({
@@ -70,19 +73,11 @@ export default class DaemonCall extends Command {
7073
...(timeoutMs === undefined ? {} : { timeoutMs }),
7174
});
7275
} catch (error) {
73-
if (
74-
isErrorWithCode(error, 'ENOENT') ||
75-
isErrorWithCode(error, 'ECONNREFUSED')
76-
) {
77-
this.error('Daemon is not running. Start it with `mm daemon start`.');
78-
}
79-
this.error(error instanceof Error ? error.message : String(error));
76+
this.error(makeDaemonConnectionError(error));
8077
}
8178

8279
if (isJsonRpcFailure(response)) {
83-
this.error(
84-
`${response.error.message} (code ${String(response.error.code)})`,
85-
);
80+
this.error(formatJsonRpcError(response.error));
8681
}
8782

8883
const isTTY = process.stdout.isTTY ?? false;
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { sendCommand } from '../../daemon/daemon-client';
2+
import { runCommand } from '../../test/run-command';
3+
import DaemonList from './list';
4+
5+
jest.mock('../../daemon/daemon-client');
6+
7+
const mockSendCommand = jest.mocked(sendCommand);
8+
9+
/**
10+
* Force `process.stdout.isTTY` for the duration of `fn`, restoring it after.
11+
*
12+
* @param value - The value to assign to `process.stdout.isTTY`.
13+
* @param fn - The callback to run while the override is in place.
14+
*/
15+
async function withTTY(
16+
value: boolean | undefined,
17+
fn: () => Promise<void>,
18+
): Promise<void> {
19+
const original = process.stdout.isTTY;
20+
Object.defineProperty(process.stdout, 'isTTY', {
21+
value,
22+
configurable: true,
23+
});
24+
try {
25+
await fn();
26+
} finally {
27+
Object.defineProperty(process.stdout, 'isTTY', {
28+
value: original,
29+
configurable: true,
30+
});
31+
}
32+
}
33+
34+
describe('daemon list', () => {
35+
beforeEach(() => {
36+
mockSendCommand.mockResolvedValue({
37+
jsonrpc: '2.0',
38+
id: '1',
39+
result: ['NetworkController:getState', 'KeyringController:getState'],
40+
});
41+
});
42+
43+
it('requests the listActions method', async () => {
44+
await withTTY(true, async () => {
45+
await runCommand(DaemonList);
46+
});
47+
48+
expect(mockSendCommand).toHaveBeenCalledWith(
49+
expect.objectContaining({ method: 'listActions' }),
50+
);
51+
});
52+
53+
it('prints a sorted, indented action list with a count header to a TTY', async () => {
54+
await withTTY(true, async () => {
55+
const { stdout } = await runCommand(DaemonList);
56+
57+
expect(stdout).toContain('2 callable actions');
58+
expect(stdout).toContain('mm daemon call <action>');
59+
// Sorted: KeyringController before NetworkController.
60+
expect(stdout.indexOf('KeyringController:getState')).toBeLessThan(
61+
stdout.indexOf('NetworkController:getState'),
62+
);
63+
expect(stdout).toContain(' KeyringController:getState');
64+
});
65+
});
66+
67+
it('singularizes the header for a single action', async () => {
68+
mockSendCommand.mockResolvedValue({
69+
jsonrpc: '2.0',
70+
id: '1',
71+
result: ['KeyringController:getState'],
72+
});
73+
74+
await withTTY(true, async () => {
75+
const { stdout } = await runCommand(DaemonList);
76+
77+
expect(stdout).toContain('1 callable action ');
78+
});
79+
});
80+
81+
it('reports an empty registry to a TTY', async () => {
82+
mockSendCommand.mockResolvedValue({ jsonrpc: '2.0', id: '1', result: [] });
83+
84+
await withTTY(true, async () => {
85+
const { stdout } = await runCommand(DaemonList);
86+
87+
expect(stdout).toContain('no callable actions');
88+
});
89+
});
90+
91+
it('writes a bare, sorted, newline-delimited list to a pipe (non-TTY)', async () => {
92+
const writeSpy = jest
93+
.spyOn(process.stdout, 'write')
94+
.mockImplementation(() => true);
95+
96+
await withTTY(false, async () => {
97+
await runCommand(DaemonList);
98+
});
99+
100+
expect(writeSpy).toHaveBeenCalledWith(
101+
'KeyringController:getState\nNetworkController:getState\n',
102+
);
103+
writeSpy.mockRestore();
104+
});
105+
106+
it('sorts actions lexicographically, including within a shared namespace', async () => {
107+
mockSendCommand.mockResolvedValue({
108+
jsonrpc: '2.0',
109+
id: '1',
110+
result: [
111+
'KeyringController:getState',
112+
'AccountsController:listMultichainAccounts',
113+
'KeyringController:addNewAccount',
114+
],
115+
});
116+
const writeSpy = jest
117+
.spyOn(process.stdout, 'write')
118+
.mockImplementation(() => true);
119+
120+
await withTTY(false, async () => {
121+
await runCommand(DaemonList);
122+
});
123+
124+
expect(writeSpy).toHaveBeenCalledWith(
125+
'AccountsController:listMultichainAccounts\n' +
126+
'KeyringController:addNewAccount\n' +
127+
'KeyringController:getState\n',
128+
);
129+
writeSpy.mockRestore();
130+
});
131+
132+
it('treats an undefined isTTY as non-TTY', async () => {
133+
const writeSpy = jest
134+
.spyOn(process.stdout, 'write')
135+
.mockImplementation(() => true);
136+
137+
await withTTY(undefined, async () => {
138+
await runCommand(DaemonList);
139+
});
140+
141+
expect(writeSpy).toHaveBeenCalledWith(
142+
'KeyringController:getState\nNetworkController:getState\n',
143+
);
144+
writeSpy.mockRestore();
145+
});
146+
147+
it('writes nothing to a pipe when the registry is empty', async () => {
148+
mockSendCommand.mockResolvedValue({ jsonrpc: '2.0', id: '1', result: [] });
149+
const writeSpy = jest
150+
.spyOn(process.stdout, 'write')
151+
.mockImplementation(() => true);
152+
153+
await withTTY(false, async () => {
154+
await runCommand(DaemonList);
155+
});
156+
157+
expect(writeSpy).not.toHaveBeenCalled();
158+
writeSpy.mockRestore();
159+
});
160+
161+
it('returns a friendly hint when the daemon is not running (ENOENT)', async () => {
162+
mockSendCommand.mockRejectedValue(
163+
Object.assign(new Error('no such file'), { code: 'ENOENT' }),
164+
);
165+
166+
const { error } = await runCommand(DaemonList);
167+
168+
expect(error?.message).toContain('Daemon is not running');
169+
});
170+
171+
it('returns a friendly hint when the daemon refuses the connection', async () => {
172+
mockSendCommand.mockRejectedValue(
173+
Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }),
174+
);
175+
176+
const { error } = await runCommand(DaemonList);
177+
178+
expect(error?.message).toContain('Daemon is not running');
179+
});
180+
181+
it('surfaces other socket errors with the raw message', async () => {
182+
mockSendCommand.mockRejectedValue(new Error('Socket read timed out'));
183+
184+
const { error } = await runCommand(DaemonList);
185+
186+
expect(error?.message).toContain('Socket read timed out');
187+
});
188+
189+
it('handles non-Error throws from sendCommand', async () => {
190+
mockSendCommand.mockImplementation(async () =>
191+
Promise.reject('string error' as unknown as Error),
192+
);
193+
194+
const { error } = await runCommand(DaemonList);
195+
196+
expect(error?.message).toContain('string error');
197+
});
198+
199+
it('errors when the daemon returns a JSON-RPC failure response', async () => {
200+
mockSendCommand.mockResolvedValue({
201+
jsonrpc: '2.0',
202+
id: '1',
203+
error: { code: -32601, message: 'Method not found' },
204+
});
205+
206+
const { error } = await runCommand(DaemonList);
207+
208+
expect(error?.message).toContain('Method not found');
209+
expect(error?.message).toContain('-32601');
210+
});
211+
212+
it('errors when the result is not an array of strings', async () => {
213+
mockSendCommand.mockResolvedValue({
214+
jsonrpc: '2.0',
215+
id: '1',
216+
result: { not: 'an array' },
217+
});
218+
219+
const { error } = await runCommand(DaemonList);
220+
221+
expect(error?.message).toContain('unexpected action list');
222+
});
223+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { isJsonRpcFailure } from '@metamask/utils';
2+
import { Command } from '@oclif/core';
3+
4+
import { sendCommand } from '../../daemon/daemon-client';
5+
import { getDaemonPaths } from '../../daemon/paths';
6+
import {
7+
formatJsonRpcError,
8+
isStringArray,
9+
makeDaemonConnectionError,
10+
} from '../../daemon/utils';
11+
12+
export default class DaemonList extends Command {
13+
static override description =
14+
'List the messenger actions the running daemon can dispatch via `daemon call`';
15+
16+
static override examples = ['<%= config.bin %> daemon list'];
17+
18+
public async run(): Promise<void> {
19+
await this.parse(DaemonList);
20+
const { socketPath } = getDaemonPaths(this.config.dataDir);
21+
22+
let response;
23+
try {
24+
response = await sendCommand({ socketPath, method: 'listActions' });
25+
} catch (error) {
26+
this.error(makeDaemonConnectionError(error));
27+
}
28+
29+
if (isJsonRpcFailure(response)) {
30+
this.error(formatJsonRpcError(response.error));
31+
}
32+
33+
if (!isStringArray(response.result)) {
34+
this.error('Daemon returned an unexpected action list.');
35+
}
36+
37+
const actions = [...response.result].sort();
38+
39+
const isTTY = process.stdout.isTTY ?? false;
40+
if (!isTTY) {
41+
// Bare output so it pipes cleanly into `grep`/`fzf`.
42+
if (actions.length > 0) {
43+
process.stdout.write(`${actions.join('\n')}\n`);
44+
}
45+
return;
46+
}
47+
48+
if (actions.length === 0) {
49+
this.log('The daemon has no callable actions registered.');
50+
return;
51+
}
52+
53+
this.log(
54+
`${actions.length} callable action${actions.length === 1 ? '' : 's'} ` +
55+
'(dispatch with `mm daemon call <action>`):',
56+
);
57+
for (const action of actions) {
58+
this.log(` ${action}`);
59+
}
60+
}
61+
}

0 commit comments

Comments
 (0)