@robota-sdk/agent-command 3.0.0-beta.81 → 3.0.0-beta.83

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +1044 -0
  2. package/dist/node/index.cjs +76 -69
  3. package/dist/node/index.d.cts +100 -8
  4. package/dist/node/index.d.cts.map +1 -1
  5. package/dist/node/index.d.ts +100 -8
  6. package/dist/node/index.d.ts.map +1 -1
  7. package/dist/node/index.js +71 -64
  8. package/dist/node/index.js.map +1 -1
  9. package/package.json +19 -9
  10. package/src/command-module-utils.test.ts +37 -0
  11. package/src/command-module-utils.ts +2 -0
  12. package/src/default/__tests__/default-command-modules.test.ts +50 -11
  13. package/src/default/__tests__/model-exposure.test.ts +16 -3
  14. package/src/default/__tests__/org-policy-forwarding.test.ts +10 -2
  15. package/src/default/default-command-modules.ts +7 -8
  16. package/src/devices/__tests__/devices-command-module.test.ts +99 -12
  17. package/src/devices/devices-command-module.ts +108 -24
  18. package/src/devices/devices-command-port.ts +62 -1
  19. package/src/devices/index.ts +3 -0
  20. package/src/doctor/doctor-node-deps.ts +6 -7
  21. package/src/editor/editor-command-module.ts +5 -0
  22. package/src/events/__tests__/events-command.test.ts +73 -0
  23. package/src/events/events-command-module.ts +49 -0
  24. package/src/events/events-command.ts +69 -0
  25. package/src/events/index.ts +2 -0
  26. package/src/git/__tests__/git-command-module.test.ts +10 -2
  27. package/src/handoff/__tests__/handoff-command-module.test.ts +25 -0
  28. package/src/handoff/__tests__/handoff-command.test.ts +3 -3
  29. package/src/handoff/handoff-command-module.ts +6 -5
  30. package/src/handoff/handoff-command.ts +4 -4
  31. package/src/help/__tests__/help-command-module.test.ts +3 -0
  32. package/src/help/__tests__/help-command.test.ts +3 -0
  33. package/src/index.ts +8 -0
  34. package/src/keybindings/keybindings-command-module.ts +17 -3
  35. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +41 -0
  36. package/src/mcp-activation/mcp-activation-command.ts +8 -3
  37. package/src/peers/__tests__/peers-command-module.test.ts +24 -0
  38. package/src/peers/__tests__/peers-command.test.ts +158 -0
  39. package/src/peers/peers-command-module.ts +4 -2
  40. package/src/peers/peers-command.ts +82 -4
  41. package/src/rewind/__tests__/rewind-command-module.test.ts +31 -1
  42. package/src/rewind/rewind-command.ts +23 -2
  43. package/src/schedule/__tests__/schedule-redos.test.ts +33 -0
  44. package/src/schedule/loop-command.ts +3 -1
  45. package/src/shell/shell-command-module.ts +5 -0
  46. package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
  47. package/src/terminal-client/index.ts +5 -0
  48. package/src/terminal-client/terminal-client-commands.ts +109 -0
  49. package/src/theme/__tests__/theme-command.test.ts +1 -1
  50. package/src/theme/theme-command-module.ts +12 -2
  51. package/src/user-local/__tests__/user-local-command.test.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-command",
3
- "version": "3.0.0-beta.81",
3
+ "version": "3.0.0-beta.83",
4
4
  "description": "Consolidated command module implementations for Robota SDK CLI",
5
5
  "type": "module",
6
6
  "main": "dist/node/index.js",
@@ -28,19 +28,24 @@
28
28
  "default": "./dist/node/index.cjs"
29
29
  }
30
30
  }
31
- }
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "engines": {
35
+ "node": ">=22.12.0"
32
36
  },
33
37
  "files": [
34
38
  "dist",
35
- "src"
39
+ "src",
40
+ "CHANGELOG.md"
36
41
  ],
37
42
  "dependencies": {
38
- "@robota-sdk/agent-core": "3.0.0-beta.81",
39
- "@robota-sdk/agent-framework": "3.0.0-beta.81",
40
- "@robota-sdk/agent-interface-execution": "3.0.0-beta.81",
41
- "@robota-sdk/agent-interface-command": "3.0.0-beta.81",
42
- "@robota-sdk/agent-preset": "3.0.0-beta.81",
43
- "@robota-sdk/agent-interface-session": "3.0.0-beta.81"
43
+ "@robota-sdk/agent-core": "3.0.0-beta.83",
44
+ "@robota-sdk/agent-framework": "3.0.0-beta.83",
45
+ "@robota-sdk/agent-interface-command": "3.0.0-beta.83",
46
+ "@robota-sdk/agent-interface-execution": "3.0.0-beta.83",
47
+ "@robota-sdk/agent-interface-session": "3.0.0-beta.83",
48
+ "@robota-sdk/agent-preset": "3.0.0-beta.83"
44
49
  },
45
50
  "devDependencies": {
46
51
  "@types/node": "^22.19.21",
@@ -50,6 +55,11 @@
50
55
  "vitest": "^3.2.6"
51
56
  },
52
57
  "license": "AGPL-3.0-only OR LicenseRef-Commercial",
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "https://github.com/woojubb/robota.git",
61
+ "directory": "packages/agent-command"
62
+ },
53
63
  "publishConfig": {
54
64
  "access": "public"
55
65
  },
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createSystemCommandFromEntry } from './command-module-utils.js';
4
+
5
+ const behavior = {
6
+ lifecycle: 'inline',
7
+ requiresPermission: false,
8
+ execute: () => ({ success: true, message: 'ran' }),
9
+ } as const;
10
+
11
+ describe('createSystemCommandFromEntry', () => {
12
+ it('#3189: carries where the entry says it runs, so the catalog and the palette agree', () => {
13
+ const command = createSystemCommandFromEntry(
14
+ {
15
+ name: 'local',
16
+ description: 'Runs on the terminal',
17
+ source: 'test',
18
+ runner: 'client',
19
+ surfaces: ['terminal'],
20
+ },
21
+ behavior,
22
+ );
23
+
24
+ expect(command.runner).toBe('client');
25
+ expect(command.surfaces).toEqual(['terminal']);
26
+ });
27
+
28
+ it('leaves an unstated placement unstated rather than guessing one', () => {
29
+ const command = createSystemCommandFromEntry(
30
+ { name: 'plain', description: 'Runs on the runtime', source: 'test' },
31
+ behavior,
32
+ );
33
+
34
+ expect('runner' in command).toBe(false);
35
+ expect('surfaces' in command).toBe(false);
36
+ });
37
+ });
@@ -25,6 +25,8 @@ export function createSystemCommandFromEntry(
25
25
  ...(entry.argumentHint !== undefined ? { argumentHint: entry.argumentHint } : {}),
26
26
  ...(entry.safety !== undefined ? { safety: entry.safety } : {}),
27
27
  ...(entry.subcommands !== undefined ? { subcommands: entry.subcommands } : {}),
28
+ ...(entry.runner !== undefined ? { runner: entry.runner } : {}),
29
+ ...(entry.surfaces !== undefined ? { surfaces: entry.surfaces } : {}),
28
30
  ...(behavior.semanticRole !== undefined ? { semanticRole: behavior.semanticRole } : {}),
29
31
  lifecycle: behavior.lifecycle,
30
32
  requiresPermission: behavior.requiresPermission,
@@ -1,4 +1,8 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { afterAll, describe, expect, it } from 'vitest';
2
6
 
3
7
  import type { IProviderDefinition } from '@robota-sdk/agent-core';
4
8
  import type {
@@ -8,6 +12,10 @@ import type {
8
12
 
9
13
  import { createDefaultCommandModules } from '../default-command-modules.js';
10
14
 
15
+ /** User-local storage lives in a private per-run directory, never a fixed name under /tmp. */
16
+ const USER_LOCAL_STORAGE_ROOT = mkdtempSync(join(tmpdir(), 'robota-test-'));
17
+ afterAll(() => rmSync(USER_LOCAL_STORAGE_ROOT, { recursive: true, force: true }));
18
+
11
19
  const providerDefinitions: readonly IProviderDefinition[] = [
12
20
  {
13
21
  type: 'anthropic',
@@ -26,7 +34,7 @@ const providerSettingsAdapter: IProviderCommandSettingsAdapter = {
26
34
  writeTargetSettings: () => undefined,
27
35
  };
28
36
 
29
- const baseOptions = { cwd: '/tmp', userLocalStorageRoot: '/tmp/robota-test', providerDefinitions, providerSettingsAdapter } as const;
37
+ const baseOptions = { cwd: '/tmp', userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT, providerDefinitions, providerSettingsAdapter } as const;
30
38
 
31
39
  function moduleNames(opts: Parameters<typeof createDefaultCommandModules>[0]): string[] {
32
40
  return createDefaultCommandModules(opts).modules.map((module) => module.name);
@@ -45,7 +53,7 @@ describe('createDefaultCommandModules — PRESET-004 module-selection delta', ()
45
53
  // `/output-style`; FLOW-008 added `/effort`; `/advisor` joined beside it). The list below is the
46
54
  // assertion that matters — a length on its own can be restored by any substitution, and the count
47
55
  // exists only to catch an addition that also removed something.
48
- expect(names).toHaveLength(35);
56
+ expect(names).toHaveLength(38);
49
57
  expect(names).toEqual([
50
58
  'agent-command-skills',
51
59
  'agent-command-help',
@@ -66,6 +74,8 @@ describe('createDefaultCommandModules — PRESET-004 module-selection delta', ()
66
74
  'agent-command-shell',
67
75
  'agent-command-editor',
68
76
  'agent-command-git',
77
+ 'agent-command-keybindings',
78
+ 'agent-command-theme',
69
79
  'agent-command-memory',
70
80
  'agent-command-mcp-activation',
71
81
  'agent-command-user-local',
@@ -80,6 +90,7 @@ describe('createDefaultCommandModules — PRESET-004 module-selection delta', ()
80
90
  'agent-command-plugin',
81
91
  'agent-command-settings',
82
92
  'agent-command-peers',
93
+ 'agent-command-events',
83
94
  'agent-command-handoff',
84
95
  'agent-command-remote-control',
85
96
  'agent-command-provider',
@@ -92,14 +103,42 @@ describe('createDefaultCommandModules — PRESET-004 module-selection delta', ()
92
103
  expect(names).toHaveLength(2);
93
104
  });
94
105
 
95
- it('BEHAVIOR-2003: registers /keybindings only when the file capability is injected', () => {
96
- expect(moduleNames(baseOptions)).not.toContain('agent-command-keybindings');
97
- expect(
98
- moduleNames({
99
- ...baseOptions,
100
- keybindingsFilePort: { ensureFile: async () => '/tmp/keybindings.json' },
101
- }),
102
- ).toContain('agent-command-keybindings');
106
+ it('#3186: /keybindings and /theme exist on a host without a terminal, and say where they work', async () => {
107
+ const { modules } = createDefaultCommandModules(baseOptions);
108
+ const commands = modules.flatMap((module) => module.systemCommands ?? []);
109
+ for (const name of ['keybindings', 'theme']) {
110
+ const command = commands.find((candidate) => candidate.name === name);
111
+ expect(command, name).toBeDefined();
112
+ const result = await command!.execute({} as never, '');
113
+ expect(result).toMatchObject({ success: false });
114
+ expect(result.message).toMatch(/robota terminal/);
115
+ }
116
+ });
117
+
118
+ it('#3189: exactly /editor, /keybindings, /shell and /theme run on the terminal client', () => {
119
+ const { modules } = createDefaultCommandModules(baseOptions);
120
+ const TERMINAL_CLIENT = ['editor', 'keybindings', 'shell', 'theme'];
121
+
122
+ // The executable commands: what the runtime keeps in its catalog and what the listing reads.
123
+ const clientSystemCommands = modules
124
+ .flatMap((module) => module.systemCommands ?? [])
125
+ .filter((command) => command.runner === 'client');
126
+ expect(clientSystemCommands.map((command) => command.name).sort()).toEqual(TERMINAL_CLIENT);
127
+ for (const command of clientSystemCommands) {
128
+ expect(command.surfaces, command.name).toEqual(['terminal']);
129
+ }
130
+
131
+ // The palette entries, which must say the same — the two views cannot disagree.
132
+ const clientEntries = modules
133
+ .flatMap((module) => module.commandSources ?? [])
134
+ .flatMap((source) => source.getCommands())
135
+ .filter((entry) => entry.runner === 'client');
136
+ expect(clientEntries.map((entry) => entry.name).sort()).toEqual(TERMINAL_CLIENT);
137
+ for (const entry of clientEntries) {
138
+ expect(entry.surfaces, entry.name).toEqual(['terminal']);
139
+ // Running on the client does not change who may run it: all four stay user-only.
140
+ expect(entry.modelInvocable, entry.name).toBe(false);
141
+ }
103
142
  });
104
143
 
105
144
  it('TC-02: disabledCommandModules blacklist removes the named module', () => {
@@ -7,7 +7,11 @@
7
7
  * each command's entry.
8
8
  */
9
9
 
10
- import { afterEach, describe, expect, it, vi } from 'vitest';
10
+ import { mkdtempSync, rmSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
11
15
 
12
16
  import { SystemCommandExecutor } from '@robota-sdk/agent-framework';
13
17
  import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
@@ -41,6 +45,10 @@ const themeCataloguePort: IThemeCataloguePort = {
41
45
  }),
42
46
  };
43
47
 
48
+ /** User-local storage lives in a private per-run directory, never a fixed name under /tmp. */
49
+ const USER_LOCAL_STORAGE_ROOT = mkdtempSync(join(tmpdir(), 'robota-test-'));
50
+ afterAll(() => rmSync(USER_LOCAL_STORAGE_ROOT, { recursive: true, force: true }));
51
+
44
52
  const fixtures: IDoctorFixture[] = [];
45
53
  afterEach(() => {
46
54
  for (const fixture of fixtures.splice(0)) fixture.cleanup();
@@ -52,12 +60,16 @@ function allBuiltInCommands(): ISystemCommand[] {
52
60
  fixtures.push(fixture);
53
61
  const { modules } = createDefaultCommandModules({
54
62
  cwd: '/tmp',
55
- userLocalStorageRoot: '/tmp/robota-test',
63
+ userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT,
56
64
  providerDefinitions,
57
65
  providerSettingsAdapter,
58
66
  });
67
+ // /keybindings and /theme are in the default set; built here with their capabilities instead.
68
+ const withoutPortless = modules.filter(
69
+ (module) => module.name !== 'agent-command-keybindings' && module.name !== 'agent-command-theme',
70
+ );
59
71
  return [
60
- ...modules,
72
+ ...withoutPortless,
61
73
  createKeybindingsCommandModule({ ensureFile: async () => '/tmp/keybindings.json' }),
62
74
  createThemeCommandModule(themeCataloguePort),
63
75
  createDoctorCommandModule(fixture.inputs, fixture.deps),
@@ -94,6 +106,7 @@ const USER_ONLY_COMMANDS = [
94
106
  'plugin',
95
107
  'reload-plugins',
96
108
  'remote-control',
109
+ 'events',
97
110
  // Account and credential actions.
98
111
  'provider',
99
112
  // Permission widening or permission-mode changes.
@@ -17,7 +17,11 @@
17
17
  * type checker's job.
18
18
  */
19
19
 
20
- import { describe, expect, it } from 'vitest';
20
+ import { mkdtempSync, rmSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+
24
+ import { afterAll, describe, expect, it } from 'vitest';
21
25
  import { SystemCommandExecutor } from '@robota-sdk/agent-framework';
22
26
  import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
23
27
 
@@ -55,13 +59,17 @@ const adapter: IProviderCommandSettingsAdapter = {
55
59
  writeTargetSettings: () => undefined,
56
60
  };
57
61
 
62
+ /** User-local storage lives in a private per-run directory, never a fixed name under /tmp. */
63
+ const USER_LOCAL_STORAGE_ROOT = mkdtempSync(join(tmpdir(), 'robota-test-'));
64
+ afterAll(() => rmSync(USER_LOCAL_STORAGE_ROOT, { recursive: true, force: true }));
65
+
58
66
  const POLICY: IOrgPolicy = { allowedProviders: ['anthropic'], adminContact: 'ops@example.com' };
59
67
 
60
68
  /** Build the provider executor the way the PRODUCT does — through the default-modules factory. */
61
69
  function executorThroughFactory(orgPolicy?: IOrgPolicy): SystemCommandExecutor {
62
70
  const { modules } = createDefaultCommandModules({
63
71
  cwd: '/work',
64
- userLocalStorageRoot: '/tmp/robota-test',
72
+ userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT,
65
73
  providerDefinitions,
66
74
  providerSettingsAdapter: adapter,
67
75
  ...(orgPolicy === undefined ? {} : { orgPolicy }),
@@ -22,6 +22,7 @@ import { createMemoryCommandModule } from '../memory/index.js';
22
22
  import { createModeCommandModule } from '../mode/index.js';
23
23
  import { createSandboxCommandModule } from '../sandbox/index.js';
24
24
  import { createOutputStyleCommandModule } from '../output-style/index.js';
25
+ import { createEventsCommandModule } from '../events/index.js';
25
26
  import { createPeersCommandModule } from '../peers/index.js';
26
27
  import { createPermissionsCommandModule } from '../permissions/index.js';
27
28
  import { createPlanCommandModule } from '../plan/index.js';
@@ -72,12 +73,11 @@ export interface IDefaultCommandModulesOptions {
72
73
  * red when the producer dropped it.
73
74
  */
74
75
  orgPolicy?: IOrgPolicy;
75
- /** Optional TUI-owned file capability; absence means `/keybindings` is not registered. */
76
+ /** Optional TUI-owned file capability; absent, `/keybindings` says it belongs to the robota terminal. */
76
77
  keybindingsFilePort?: IKeybindingsFilePort;
77
78
  /**
78
- * SCREEN-2002: the surface's theme catalogue. Absence means `/theme` is not registered at all —
79
- * print mode and `--serve` render no themes, and a command that cannot do anything is better
80
- * missing than present-and-failing.
79
+ * SCREEN-2002: the surface's theme catalogue. Absent (print mode, `--serve`), `/theme` is still
80
+ * listed and says it belongs to the robota terminal, which runs it itself when attached.
81
81
  */
82
82
  themeCataloguePort?: IThemeCataloguePort;
83
83
  /**
@@ -173,10 +173,8 @@ export function createDefaultCommandModules({
173
173
  createShellCommandModule(),
174
174
  createEditorCommandModule(editorTemporaryDirectoryPrefix),
175
175
  createGitCommandModule(),
176
- ...(keybindingsFilePort === undefined
177
- ? []
178
- : [createKeybindingsCommandModule(keybindingsFilePort)]),
179
- ...(themeCataloguePort === undefined ? [] : [createThemeCommandModule(themeCataloguePort)]),
176
+ createKeybindingsCommandModule(keybindingsFilePort),
177
+ createThemeCommandModule(themeCataloguePort),
180
178
  ...(doctorInputs === undefined
181
179
  ? []
182
180
  : [createDoctorCommandModule(doctorInputs, undefined, doctorDisplay)]),
@@ -194,6 +192,7 @@ export function createDefaultCommandModules({
194
192
  createPluginCommandModule(),
195
193
  createSettingsCommandModule(),
196
194
  createPeersCommandModule(),
195
+ createEventsCommandModule(),
197
196
  // HANDOFF-001 (issue #1864). Registered even though no product wires the carrier adapter yet:
198
197
  // the command's own answer to a host without one is to say so, which is a better state than a
199
198
  // capability nobody can see. It is also what makes the carrier's arrival observable.
@@ -44,7 +44,12 @@ function fakePort(): IDevicesCommandPort & { calls: string[] } {
44
44
  calls.push(`init:${options.name ?? ''}`);
45
45
  return {
46
46
  ok: true as const,
47
- value: { userId: USER, deviceId: DEVICE_A, signingKeyId: SIGNING, keyStorage: 'OS keychain' },
47
+ value: {
48
+ userId: USER,
49
+ deviceId: DEVICE_A,
50
+ signingKeyId: SIGNING,
51
+ keyStorage: 'OS keychain',
52
+ },
48
53
  };
49
54
  }),
50
55
  recover: vi.fn(async () => {
@@ -63,6 +68,20 @@ function fakePort(): IDevicesCommandPort & { calls: string[] } {
63
68
  calls.push(`revoke:${target}`);
64
69
  return { ok: true as const, value: { deviceId: DEVICE_B, name: 'desktop' } };
65
70
  }),
71
+ add: vi.fn(async () => {
72
+ calls.push('add');
73
+ return {
74
+ ok: true as const,
75
+ value: { deviceId: DEVICE_B, name: 'desktop', confirmed: true },
76
+ };
77
+ }),
78
+ join: vi.fn(async (options) => {
79
+ calls.push(`join:${options.name ?? ''}`);
80
+ return {
81
+ ok: true as const,
82
+ value: { userId: USER, deviceId: DEVICE_B, name: 'desktop', keyStorage: 'OS keychain' },
83
+ };
84
+ }),
66
85
  };
67
86
  }
68
87
 
@@ -105,7 +124,19 @@ describe('/devices command', () => {
105
124
  'init',
106
125
  'revoke',
107
126
  'recover',
127
+ 'add',
128
+ 'join',
108
129
  ]);
130
+ // What the model reads names the one-time code as terminal-only, so it never asks for it.
131
+ expect(palette?.description).toMatch(/enrol/i);
132
+ expect(palette?.description).toMatch(/terminal/i);
133
+ });
134
+
135
+ it('tells the model it also shows the device mesh status', () => {
136
+ const description =
137
+ createDevicesCommandModule(fakePort()).commandSources?.[0]?.getCommands()[0]?.description;
138
+ expect(description).toMatch(/device mesh/);
139
+ expect(description).toMatch(/Operator-only/);
109
140
  });
110
141
 
111
142
  it('lists the roster with short ids, this device, the signing-key holder and expiry', async () => {
@@ -125,6 +156,17 @@ describe('/devices command', () => {
125
156
  vi.mocked(port.list).mockResolvedValueOnce(undefined);
126
157
  const result = await start(port).command('devices', 'list');
127
158
  expect(result?.message).toMatch(/\/devices init/);
159
+ // A device of a user who already has others joins them; `init` would never link to them.
160
+ expect(result?.message).toMatch(/\/devices join/);
161
+ expect(result?.message).toMatch(/separate identity/);
162
+ });
163
+
164
+ it('describes init as for the first device, and join as the way to the devices a user has', () => {
165
+ const entry = createDevicesCommandModule(fakePort()).commandSources?.[0]?.getCommands()[0];
166
+ const init = entry?.subcommands?.find((sub) => sub.name === 'init');
167
+ expect(init?.description).toMatch(/first device/);
168
+ expect(init?.description).toMatch(/separate identity/);
169
+ expect(entry?.description).toMatch(/separate identity/);
128
170
  });
129
171
 
130
172
  it('runs init, recover and revoke on the operator terminal', async () => {
@@ -156,7 +198,7 @@ describe('/devices command', () => {
156
198
  it('refuses without an interactive terminal and never reaches the port (fail closed)', async () => {
157
199
  const port = fakePort();
158
200
  const harness = start(port, handoff(false));
159
- for (const args of ['init', 'recover', 'revoke abcdef']) {
201
+ for (const args of ['init', 'recover', 'revoke abcdef', 'add', 'join', 'join desktop']) {
160
202
  const result = await harness.command('devices', args);
161
203
  expect(result?.success).toBe(false);
162
204
  expect(result?.message).toMatch(/interactive terminal/i);
@@ -167,15 +209,17 @@ describe('/devices command', () => {
167
209
  it('refuses a model invocation', async () => {
168
210
  const port = fakePort();
169
211
  const harness = start(port);
170
- const result = await harness.session.executeModelCommand('devices', 'init');
171
- expect(result?.success ?? false).toBe(false);
212
+ for (const args of ['init', 'add', 'join']) {
213
+ const result = await harness.session.executeModelCommand('devices', args);
214
+ expect(result?.success ?? false).toBe(false);
215
+ }
172
216
  expect(port.calls).toEqual([]);
173
217
  });
174
218
 
175
219
  it('refuses every verb from a remote surface', async () => {
176
220
  const port = fakePort();
177
221
  const harness = start(port);
178
- for (const args of ['', 'init', 'recover', 'revoke abcdef']) {
222
+ for (const args of ['', 'init', 'recover', 'revoke abcdef', 'add', 'join']) {
179
223
  const result = await harness.session.executeCommand('devices', args, 'remote');
180
224
  expect(result?.success).toBe(false);
181
225
  expect(result?.message).toMatch(/operator/i);
@@ -183,15 +227,58 @@ describe('/devices command', () => {
183
227
  expect(port.calls).toEqual([]);
184
228
  });
185
229
 
186
- it('names add and join as not yet available rather than guessing', async () => {
230
+ it('shows the device mesh: whether it is on, how it finds devices, and what is linked', async () => {
231
+ const port = {
232
+ ...fakePort(),
233
+ meshStatus: () => ({
234
+ state: 'on' as const,
235
+ sources: ['local network (mDNS)', 'Mainline DHT'],
236
+ linked: [{ deviceId: DEVICE_B, name: 'desktop', locality: 'another-host' as const }],
237
+ }),
238
+ };
239
+ const result = await start(port).command('devices', '');
240
+ expect(result?.message).toMatch(/Device mesh: on/);
241
+ expect(result?.message).toContain('local network (mDNS), Mainline DHT');
242
+ expect(result?.message).toMatch(
243
+ new RegExp(`${DEVICE_B.slice(0, 10)}\\s+desktop\\s+on another machine`),
244
+ );
245
+ });
246
+
247
+ it('says how to turn the device mesh on when it is off', async () => {
248
+ const port = {
249
+ ...fakePort(),
250
+ meshStatus: () => ({ state: 'off' as const, sources: [], linked: [] }),
251
+ };
252
+ const result = await start(port).command('devices', 'list');
253
+ expect(result?.message).toMatch(/Device mesh: off/);
254
+ expect(result?.message).toContain('transports.mesh.enabled');
255
+ });
256
+
257
+ it('runs add and join on the operator terminal and reports ids and names only', async () => {
258
+ const port = fakePort();
259
+ const runs = vi.fn();
260
+ const harness = start(port, handoff(true, runs));
261
+ const add = await harness.command('devices', 'add');
262
+ expect(add?.success).toBe(true);
263
+ expect(add?.message).toContain('desktop');
264
+ expect(add?.message).toContain(DEVICE_B.slice(0, 10));
265
+ const join = await harness.command('devices', 'join my desktop');
266
+ expect(join?.success).toBe(true);
267
+ expect(join?.message).toContain(DEVICE_B.slice(0, 10));
268
+ expect(join?.message).toContain('OS keychain');
269
+ expect(port.calls).toEqual(['add', 'join:my desktop']);
270
+ expect(runs).toHaveBeenCalledTimes(2);
271
+ });
272
+
273
+ it('says what to do when an enrollment is refused', async () => {
187
274
  const port = fakePort();
188
275
  const harness = start(port);
189
- for (const args of ['add', 'join abc']) {
190
- const result = await harness.command('devices', args);
191
- expect(result?.success).toBe(false);
192
- expect(result?.message).toMatch(/not available yet/i);
193
- }
194
- expect(port.calls).toEqual([]);
276
+ vi.mocked(port.join).mockResolvedValueOnce({ ok: false, reason: 'code-not-accepted' });
277
+ expect((await harness.command('devices', 'join'))?.message).toMatch(/devices add/);
278
+ vi.mocked(port.add).mockResolvedValueOnce({ ok: false, reason: 'no-relay' });
279
+ expect((await harness.command('devices', 'add'))?.message).toMatch(/relayUrl/);
280
+ vi.mocked(port.join).mockResolvedValueOnce({ ok: false, reason: 'code-on-command-line' });
281
+ expect((await harness.command('devices', 'join ABCDE'))?.message).toMatch(/devices add/);
195
282
  });
196
283
 
197
284
  it('requires a device id for revoke', async () => {