@robota-sdk/agent-command 3.0.0-beta.82 → 3.0.0-beta.84

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 (94) hide show
  1. package/CHANGELOG.md +1071 -0
  2. package/README.md +71 -62
  3. package/dist/node/index.cjs +81 -72
  4. package/dist/node/index.d.cts +136 -52
  5. package/dist/node/index.d.cts.map +1 -1
  6. package/dist/node/index.d.ts +136 -52
  7. package/dist/node/index.d.ts.map +1 -1
  8. package/dist/node/index.js +79 -70
  9. package/dist/node/index.js.map +1 -1
  10. package/package.json +16 -10
  11. package/src/agent/__tests__/agent-command.test.ts +62 -0
  12. package/src/agent/agent-command-parser.ts +2 -2
  13. package/src/agent/agent-command.ts +20 -0
  14. package/src/command-module-utils.test.ts +37 -0
  15. package/src/command-module-utils.ts +2 -0
  16. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +2 -2
  17. package/src/default/__tests__/default-command-modules.test.ts +54 -14
  18. package/src/default/__tests__/model-exposure.test.ts +18 -3
  19. package/src/default/__tests__/org-policy-forwarding.test.ts +10 -2
  20. package/src/default/default-command-modules.ts +15 -8
  21. package/src/devices/__tests__/devices-command-module.test.ts +99 -12
  22. package/src/devices/devices-command-module.ts +108 -24
  23. package/src/devices/devices-command-port.ts +62 -1
  24. package/src/devices/index.ts +3 -0
  25. package/src/doctor/doctor-node-deps.ts +6 -7
  26. package/src/editor/editor-command-module.ts +5 -0
  27. package/src/effort/effort-command.test.ts +65 -2
  28. package/src/effort/effort-command.ts +19 -5
  29. package/src/events/__tests__/events-command.test.ts +73 -0
  30. package/src/events/events-command-module.ts +49 -0
  31. package/src/events/events-command.ts +69 -0
  32. package/src/events/index.ts +2 -0
  33. package/src/git/__tests__/fake-git-port.ts +9 -2
  34. package/src/git/__tests__/git-command-module.test.ts +10 -2
  35. package/src/git/git-command-module.ts +3 -4
  36. package/src/git/git-commit.ts +6 -3
  37. package/src/git/index.ts +4 -1
  38. package/src/goal/goal-command.ts +3 -1
  39. package/src/handoff/__tests__/handoff-command-module.test.ts +2 -1
  40. package/src/handoff/handoff-command-module.ts +1 -1
  41. package/src/handoff/handoff-command.ts +2 -2
  42. package/src/help/__tests__/help-command-module.test.ts +3 -0
  43. package/src/help/__tests__/help-command.test.ts +3 -0
  44. package/src/index.ts +14 -0
  45. package/src/keybindings/keybindings-command-module.ts +17 -3
  46. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +172 -0
  47. package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
  48. package/src/mcp-activation/mcp-activation-command.ts +62 -4
  49. package/src/memory/__tests__/memory-command-module.test.ts +40 -2
  50. package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
  51. package/src/memory/memory-command-module.ts +4 -2
  52. package/src/memory/memory-command.ts +42 -2
  53. package/src/mode/__tests__/mode-command-module.test.ts +40 -0
  54. package/src/mode/mode-command.ts +1 -1
  55. package/src/model/__tests__/model-command-module.test.ts +80 -0
  56. package/src/model/__tests__/model-command.test.ts +240 -0
  57. package/src/model/index.ts +6 -0
  58. package/src/model/model-command-module.ts +56 -0
  59. package/src/model/model-command.ts +223 -0
  60. package/src/peers/__tests__/peers-command-module.test.ts +24 -0
  61. package/src/peers/__tests__/peers-command.test.ts +78 -0
  62. package/src/peers/peers-command-module.ts +1 -1
  63. package/src/peers/peers-command.ts +33 -4
  64. package/src/plugin/__tests__/plugin-command-module.test.ts +81 -1
  65. package/src/plugin/plugin-command.ts +47 -4
  66. package/src/provider/__tests__/org-policy.test.ts +4 -0
  67. package/src/provider/__tests__/provider-command-module.test.ts +276 -14
  68. package/src/provider/__tests__/scripted-interaction.ts +12 -2
  69. package/src/provider/provider-command-execution.ts +54 -8
  70. package/src/provider/provider-command-profile-lifecycle.ts +48 -2
  71. package/src/provider/provider-command-profile-operations.ts +14 -0
  72. package/src/provider/provider-command-profile.ts +7 -4
  73. package/src/provider/provider-command-setup.ts +14 -1
  74. package/src/rewind/__tests__/rewind-command-module.test.ts +32 -1
  75. package/src/rewind/rewind-command.ts +23 -2
  76. package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
  77. package/src/schedule/__tests__/loop-command.test.ts +238 -4
  78. package/src/schedule/__tests__/schedule-redos.test.ts +33 -0
  79. package/src/schedule/loop-command.ts +98 -4
  80. package/src/schedule/schedule-command-module.ts +8 -4
  81. package/src/settings/settings-command-module.ts +2 -2
  82. package/src/shell/shell-command-module.ts +5 -0
  83. package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
  84. package/src/terminal-client/index.ts +5 -0
  85. package/src/terminal-client/terminal-client-commands.ts +109 -0
  86. package/src/theme/__tests__/theme-command.test.ts +1 -1
  87. package/src/theme/theme-command-module.ts +12 -2
  88. package/src/user-local/__tests__/user-local-command.test.ts +2 -2
  89. package/src/git/__tests__/git-diff.test.ts +0 -125
  90. package/src/git/__tests__/git-process.test.ts +0 -221
  91. package/src/git/__tests__/git-status.test.ts +0 -117
  92. package/src/git/git-diff.ts +0 -129
  93. package/src/git/git-process.ts +0 -169
  94. package/src/git/git-status.ts +0 -134
@@ -43,10 +43,13 @@ const MONITOR_MODEL_DESCRIPTION =
43
43
  'The command is allowed, refused or asked about by the same permission rules as a shell tool ' +
44
44
  'call. Returns the monitor task id, or a refusal naming what the user must allow.';
45
45
  const LOOP_MODEL_DESCRIPTION =
46
- 'Repeat a prompt on a fixed cadence (`<N><s|m|h|d> [prompt]`) or self-paced (bare or prompt ' +
47
- 'only: each iteration chooses its next delay or stops). Use it when the user asks for recurring ' +
48
- 'work in this session. `list` shows active loops; `stop <id>` ends one. Returns the loop id and ' +
49
- 'its next fire time, or the loop list.';
46
+ 'Repeat a prompt on a fixed cadence (`<N><s|m|h|d> <prompt>`) or self-paced (`<prompt>`: each ' +
47
+ 'iteration chooses its next delay or stops). Use it when the user asks for recurring work in ' +
48
+ 'this session, and always give your own prompt: any form that would fall back to the host\'s ' +
49
+ 'default maintenance prompt — bare `/loop` or an interval alone (`<N><s|m|h|d>`) — needs a ' +
50
+ 'person to confirm it and is always refused for you, whether or not one is attached. `list` ' +
51
+ 'shows active loops; `stop <id>` ends one, even mid-turn. Returns the loop id and its next fire ' +
52
+ 'time, the loop list, or a refusal naming the command to suggest.';
50
53
 
51
54
  export function createScheduleCommandEntry(): ICommand {
52
55
  return {
@@ -154,6 +157,7 @@ function createLoopSystemCommand(options: ILoopCommandOptions): ISystemCommand {
154
157
  (taskId, reason) => context.cancelBackgroundTask(taskId, reason),
155
158
  args,
156
159
  options,
160
+ { ui: context.getUserInteraction(), source: context.getCommandInvocationSource() },
157
161
  ),
158
162
  };
159
163
  }
@@ -5,9 +5,9 @@ export function createSettingsCommandEntry(): ICommand {
5
5
  return {
6
6
  name: 'settings',
7
7
  displayName: 'Settings',
8
- description: 'Open transport settings — enable/disable transports and configure options',
8
+ description: 'Open settings — language, output style, preset, permissions and more',
9
9
  source: 'settings',
10
- // User-only: transport settings screen; UI-only.
10
+ // User-only: opens a screen; nothing here is a model action.
11
11
  modelInvocable: false,
12
12
  };
13
13
  }
@@ -14,6 +14,9 @@ export function createShellCommandEntry(): ICommand {
14
14
  source: 'shell',
15
15
  // User-only: the user's own shell passthrough; the model has its permission-gated shell tool.
16
16
  modelInvocable: false,
17
+ // It takes over the terminal the user sits at, so that terminal runs it, even when attached.
18
+ runner: 'client',
19
+ surfaces: ['terminal'],
17
20
  };
18
21
  }
19
22
 
@@ -26,6 +29,8 @@ function createShellSystemCommand(executable?: string): ISystemCommand {
26
29
  requiresPermission: true,
27
30
  userInvocable: true,
28
31
  modelInvocable: false,
32
+ runner: entry.runner,
33
+ surfaces: entry.surfaces,
29
34
  lifecycle: 'inline',
30
35
  execute: (context, args) => executeShellCommand(context, args, executable),
31
36
  };
@@ -0,0 +1,216 @@
1
+ /**
2
+ * #3189 — the commands a terminal attached to a workspace daemon runs in its own process.
3
+ *
4
+ * The attached terminal routes by this set, not by the daemon's catalog, so the set must name exactly
5
+ * the commands the default assembly marks `runner: 'client'` — a command in one list and not the
6
+ * other would either run on the daemon or never run at all. And each client command must answer as
7
+ * the in-process command does, because it is the same command running somewhere else.
8
+ */
9
+ import { mkdtempSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ import { afterAll, describe, expect, it, vi } from 'vitest';
14
+
15
+ import { createDefaultCommandModules } from '../../default/index.js';
16
+ import { createEditorCommandModule } from '../../editor/index.js';
17
+ import { createKeybindingsCommandModule } from '../../keybindings/index.js';
18
+ import { createShellCommandModule } from '../../shell/index.js';
19
+ import { createThemeCommandModule } from '../../theme/index.js';
20
+ import { createTerminalClientCommands } from '../terminal-client-commands.js';
21
+
22
+ import type { ITerminalClientCommand } from '../terminal-client-commands.js';
23
+ import type { IProviderDefinition } from '@robota-sdk/agent-core';
24
+ import type {
25
+ ICommandHostContext,
26
+ ICommandHostTerminalHandoff,
27
+ ICommandHostWorkspace,
28
+ ICommandModule,
29
+ IProviderCommandSettingsAdapter,
30
+ TProviderSettingsDocument,
31
+ } from '@robota-sdk/agent-framework';
32
+ import type {
33
+ IThemeCatalogueEntry,
34
+ IThemeCataloguePort,
35
+ } from '@robota-sdk/agent-interface-command';
36
+
37
+ /** User-local storage lives in a private per-run directory, never a fixed name under /tmp. */
38
+ const USER_LOCAL_STORAGE_ROOT = mkdtempSync(join(tmpdir(), 'robota-test-'));
39
+ afterAll(() => rmSync(USER_LOCAL_STORAGE_ROOT, { recursive: true, force: true }));
40
+
41
+ const providerDefinitions: readonly IProviderDefinition[] = [];
42
+ const providerSettingsAdapter: IProviderCommandSettingsAdapter = {
43
+ readMergedSettings: () => ({}) as TProviderSettingsDocument,
44
+ readTargetSettings: () => ({}) as TProviderSettingsDocument,
45
+ writeTargetSettings: () => undefined,
46
+ };
47
+
48
+ const THEMES: IThemeCatalogueEntry[] = [
49
+ { id: 'dark', name: 'Dark', appearance: 'dark', source: 'built-in' },
50
+ { id: 'light', name: 'Light', appearance: 'light', source: 'built-in' },
51
+ ];
52
+ const themeCatalogue: IThemeCataloguePort = {
53
+ listThemes: () => THEMES,
54
+ getTheme: (id) => THEMES.find((theme) => theme.id === id),
55
+ getAppearance: () => ({
56
+ settings: { theme: 'dark', syntaxHighlighting: true, reducedMotion: false },
57
+ }),
58
+ };
59
+
60
+ function allPortsGiven(): readonly ITerminalClientCommand[] {
61
+ return createTerminalClientCommands({
62
+ keybindingsFile: { ensureFile: async () => '/tmp/keybindings.json' },
63
+ themeCatalogue,
64
+ });
65
+ }
66
+
67
+ function find(commands: readonly ITerminalClientCommand[], name: string): ITerminalClientCommand {
68
+ const command = commands.find((candidate) => candidate.name === name);
69
+ if (!command) throw new Error(`no terminal client command "${name}"`);
70
+ return command;
71
+ }
72
+
73
+ /** A terminal the command cannot take over: a host with no interactive TTY. */
74
+ function hostWithoutTerminal(): ICommandHostTerminalHandoff & ICommandHostWorkspace {
75
+ return {
76
+ canHandoffTerminal: () => false,
77
+ runWithTerminal: vi.fn(async () => {
78
+ throw new Error('a refused handoff must not be attempted');
79
+ }),
80
+ getCwd: () => '/tmp',
81
+ getCommandInvocationSource: () => 'user',
82
+ };
83
+ }
84
+
85
+ /** What the in-process command answers for the same host and arguments. */
86
+ async function inProcess(module: ICommandModule, host: unknown, args: string) {
87
+ const command = module.systemCommands?.[0];
88
+ if (!command) throw new Error(`module ${module.name} has no command`);
89
+ return command.execute(host as ICommandHostContext, args);
90
+ }
91
+
92
+ describe('createTerminalClientCommands (#3189)', () => {
93
+ it('names exactly the commands the default assembly marks as client-run', () => {
94
+ const { modules } = createDefaultCommandModules({
95
+ cwd: '/tmp',
96
+ userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT,
97
+ providerDefinitions,
98
+ providerSettingsAdapter,
99
+ });
100
+ const clientRun = modules
101
+ .flatMap((module) => module.systemCommands ?? [])
102
+ .filter((command) => command.runner === 'client')
103
+ .map((command) => command.name)
104
+ .sort();
105
+
106
+ expect(clientRun.length).toBeGreaterThan(0);
107
+ expect(
108
+ allPortsGiven()
109
+ .map((command) => command.name)
110
+ .sort(),
111
+ ).toEqual(clientRun);
112
+ });
113
+
114
+ it('leaves out a client command whose module the selection disables, as the default assembly does', () => {
115
+ const selection = { disabledCommandModules: ['agent-command-shell'] };
116
+ const { modules } = createDefaultCommandModules({
117
+ cwd: '/tmp',
118
+ userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT,
119
+ providerDefinitions,
120
+ providerSettingsAdapter,
121
+ ...selection,
122
+ });
123
+ const clientRun = modules
124
+ .flatMap((module) => module.systemCommands ?? [])
125
+ .filter((command) => command.runner === 'client')
126
+ .map((command) => command.name)
127
+ .sort();
128
+
129
+ const names = createTerminalClientCommands({ themeCatalogue, ...selection })
130
+ .map((command) => command.name)
131
+ .sort();
132
+
133
+ expect(names).not.toContain('shell');
134
+ expect(names).toEqual(clientRun);
135
+ });
136
+
137
+ it('keeps only the enabled client modules, and a disabled name wins over an enabled one', () => {
138
+ const names = createTerminalClientCommands({
139
+ enabledCommandModules: ['agent-command-theme', 'agent-command-editor', 'agent-command-help'],
140
+ disabledCommandModules: ['agent-command-editor'],
141
+ }).map((command) => command.name);
142
+
143
+ expect(names).toEqual(['theme']);
144
+ });
145
+
146
+ it('with no selection, offers all four client commands', () => {
147
+ expect(
148
+ createTerminalClientCommands({})
149
+ .map((command) => command.name)
150
+ .sort(),
151
+ ).toEqual(['editor', 'keybindings', 'shell', 'theme']);
152
+ });
153
+
154
+ it('/shell refuses a host that cannot hand off the terminal exactly as the in-process command does', async () => {
155
+ const host = hostWithoutTerminal();
156
+
157
+ const client = await find(allPortsGiven(), 'shell').execute(host, 'ls');
158
+
159
+ expect(client).toEqual(await inProcess(createShellCommandModule(), host, 'ls'));
160
+ expect(client.success).toBe(false);
161
+ expect(host.runWithTerminal).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it('/editor and /keybindings refuse a host without a terminal as their in-process commands do', async () => {
165
+ const keybindingsFile = { ensureFile: vi.fn(async () => '/tmp/keybindings.json') };
166
+ const commands = createTerminalClientCommands({ keybindingsFile, themeCatalogue });
167
+
168
+ const editorHost = hostWithoutTerminal();
169
+ const editor = await find(commands, 'editor').execute(editorHost, 'draft');
170
+ expect(editor).toEqual(await inProcess(createEditorCommandModule(), editorHost, 'draft'));
171
+ expect(editor.success).toBe(false);
172
+
173
+ const keybindingsHost = hostWithoutTerminal();
174
+ const keybindings = await find(commands, 'keybindings').execute(keybindingsHost, '');
175
+ expect(keybindings).toEqual(
176
+ await inProcess(createKeybindingsCommandModule(keybindingsFile), keybindingsHost, ''),
177
+ );
178
+ expect(keybindings.success).toBe(false);
179
+ expect(keybindingsFile.ensureFile).not.toHaveBeenCalled();
180
+ });
181
+
182
+ it('/theme with no arguments asks the terminal to open its theme picker', async () => {
183
+ const result = await find(allPortsGiven(), 'theme').execute(hostWithoutTerminal(), '');
184
+
185
+ expect(result).toMatchObject({ success: true, uiIntents: [{ type: 'show-theme-picker' }] });
186
+ expect(result.hostActions).toBeUndefined();
187
+ });
188
+
189
+ it('/theme with a known id returns one appearance settings patch for the terminal to write', async () => {
190
+ const result = await find(allPortsGiven(), 'theme').execute(hostWithoutTerminal(), 'light');
191
+
192
+ expect(result.success).toBe(true);
193
+ expect(result.hostActions).toEqual([
194
+ { type: 'appearance-settings-patch', patch: { theme: 'light' } },
195
+ ]);
196
+ expect(result).toEqual(await inProcess(createThemeCommandModule(themeCatalogue), {}, 'light'));
197
+ });
198
+
199
+ it('without their ports, /keybindings and /theme still run on the terminal and answer as in-process', async () => {
200
+ const commands = createTerminalClientCommands({});
201
+ // The set does not shrink: a command missing from it would be sent to the daemon instead.
202
+ expect(commands.map((command) => command.name).sort()).toEqual(
203
+ allPortsGiven()
204
+ .map((command) => command.name)
205
+ .sort(),
206
+ );
207
+
208
+ const host = hostWithoutTerminal();
209
+ expect(await find(commands, 'keybindings').execute(host, '')).toEqual(
210
+ await inProcess(createKeybindingsCommandModule(undefined), host, ''),
211
+ );
212
+ expect(await find(commands, 'theme').execute(host, 'light')).toEqual(
213
+ await inProcess(createThemeCommandModule(undefined), host, 'light'),
214
+ );
215
+ });
216
+ });
@@ -0,0 +1,5 @@
1
+ export {
2
+ createTerminalClientCommands,
3
+ type ITerminalClientCommand,
4
+ type ITerminalClientCommandOptions,
5
+ } from './terminal-client-commands.js';
@@ -0,0 +1,109 @@
1
+ /**
2
+ * #3189: the commands that belong to the terminal the user sits at — the ones the default assembly
3
+ * marks `runner: 'client'`. A terminal attached to a workspace daemon runs these in its own process,
4
+ * on its own terminal and working directory, and sends nothing to the daemon.
5
+ *
6
+ * Each one is the in-process command's own execute function, so an attached terminal answers exactly
7
+ * as a standalone one does; only where it runs changes.
8
+ */
9
+ import { selectCommandModules } from '@robota-sdk/agent-framework';
10
+
11
+ import { createEditorCommandModule } from '../editor/editor-command-module.js';
12
+ import { executeEditorCommand } from '../editor/editor-command.js';
13
+ import {
14
+ createKeybindingsCommandModule,
15
+ executeKeybindingsCommand,
16
+ } from '../keybindings/keybindings-command-module.js';
17
+ import { createShellCommandModule } from '../shell/shell-command-module.js';
18
+ import { executeShellCommand } from '../shell/shell-command.js';
19
+ import { createThemeCommandModule, executeThemeCommand } from '../theme/theme-command-module.js';
20
+
21
+ import type { IKeybindingsFilePort } from '../keybindings/keybindings-command-module.js';
22
+ import type {
23
+ ICommandHostTerminalHandoff,
24
+ ICommandHostWorkspace,
25
+ ICommandModule,
26
+ } from '@robota-sdk/agent-framework';
27
+ import type { ICommandResult, IThemeCataloguePort } from '@robota-sdk/agent-interface-command';
28
+
29
+ /** A command the terminal runs itself, with the terminal's own handoff and working directory. */
30
+ export interface ITerminalClientCommand {
31
+ readonly name: string;
32
+ execute(
33
+ host: ICommandHostTerminalHandoff & ICommandHostWorkspace,
34
+ args: string,
35
+ ): ICommandResult | Promise<ICommandResult>;
36
+ }
37
+
38
+ /** The terminal's own capabilities — the same ones its in-process command modules are given. */
39
+ export interface ITerminalClientCommandOptions {
40
+ /** The shell `/shell` runs; absent → the platform shell, as for the in-process command. */
41
+ readonly shellExecutable?: string;
42
+ /** Product-owned prefix for `/editor`'s temporary directory; absent → the command's default. */
43
+ readonly editorTemporaryDirectoryPrefix?: string;
44
+ /** Absent → `/keybindings` answers where key bindings live, as the in-process command does. */
45
+ readonly keybindingsFile?: IKeybindingsFilePort;
46
+ /** Absent → `/theme` answers where themes live, as the in-process command does. */
47
+ readonly themeCatalogue?: IThemeCataloguePort;
48
+ /**
49
+ * The same module selection the default assembly is given (`agent-command-shell`, …): a module it
50
+ * leaves out is left out here too, so an attached terminal offers no command a standalone one
51
+ * would not. Absent → every client-run module.
52
+ */
53
+ readonly enabledCommandModules?: readonly string[];
54
+ /** Applied after `enabledCommandModules`, as in the default assembly (deny > allow). */
55
+ readonly disabledCommandModules?: readonly string[];
56
+ }
57
+
58
+ type TClientExecute = ITerminalClientCommand['execute'];
59
+
60
+ /**
61
+ * Every client-run command the module selection keeps, whether or not its port is given. The set
62
+ * shrinks only with the selection, never with a missing port: the attached terminal routes by it, so
63
+ * a selected command left out would run on the daemon instead — and without its port the command
64
+ * still answers as its in-process twin does.
65
+ */
66
+ export function createTerminalClientCommands(
67
+ options: ITerminalClientCommandOptions,
68
+ ): readonly ITerminalClientCommand[] {
69
+ const {
70
+ shellExecutable,
71
+ editorTemporaryDirectoryPrefix,
72
+ keybindingsFile,
73
+ themeCatalogue,
74
+ enabledCommandModules,
75
+ disabledCommandModules,
76
+ } = options;
77
+ // Each client command is paired with its in-process module, so selection matches by the module's
78
+ // own name and the command keeps that module's command name.
79
+ const candidates: ReadonlyArray<readonly [ICommandModule, TClientExecute]> = [
80
+ [
81
+ createShellCommandModule(shellExecutable),
82
+ (host, args) => executeShellCommand(host, args, shellExecutable),
83
+ ],
84
+ [
85
+ createEditorCommandModule(editorTemporaryDirectoryPrefix),
86
+ (host, args) => executeEditorCommand(host, args, editorTemporaryDirectoryPrefix),
87
+ ],
88
+ [
89
+ createKeybindingsCommandModule(keybindingsFile),
90
+ (host) => executeKeybindingsCommand(keybindingsFile, host),
91
+ ],
92
+ [
93
+ createThemeCommandModule(themeCatalogue),
94
+ (_host, args) => executeThemeCommand(themeCatalogue, args),
95
+ ],
96
+ ];
97
+ const selected = new Set(
98
+ selectCommandModules(
99
+ candidates.map(([module]) => module),
100
+ enabledCommandModules,
101
+ disabledCommandModules,
102
+ ),
103
+ );
104
+ return candidates
105
+ .filter(([module]) => selected.has(module))
106
+ .flatMap(([module, execute]) =>
107
+ (module.systemCommands ?? []).map((command) => ({ name: command.name, execute })),
108
+ );
109
+ }
@@ -73,7 +73,7 @@ describe('/theme without a catalogue (SCREEN-2002 TC-09)', () => {
73
73
  const result = executeThemeCommand(undefined, 'light');
74
74
 
75
75
  expect(result.success).toBe(false);
76
- expect(result.message).toBe('Themes are not available in this environment.');
76
+ expect(result.message).toMatch(/Run \/theme in the robota terminal/);
77
77
  expect(result.hostActions).toBeUndefined();
78
78
  });
79
79
  });
@@ -16,7 +16,10 @@ const THEME_DESCRIPTION =
16
16
  'List, preview or switch the terminal colour theme, and toggle syntax highlighting and motion';
17
17
  const THEME_ARGUMENT_HINT = 'list | <theme-id> | syntax on|off | motion on|off';
18
18
 
19
- const THEME_UNAVAILABLE = 'Themes are not available in this environment.';
19
+ // A host without a terminal (the desktop app's sidecar) still answers, so the command is never
20
+ // "unknown" there — it says where themes live instead.
21
+ const THEME_UNAVAILABLE =
22
+ 'Themes belong to the robota terminal, and this surface has none. Run /theme in the robota terminal.';
20
23
 
21
24
  function formatToggle(value: boolean): string {
22
25
  return value ? 'on' : 'off';
@@ -179,6 +182,9 @@ export function createThemeCommandEntry(): ICommand {
179
182
  userInvocable: true,
180
183
  // User-only: UI preference.
181
184
  modelInvocable: false,
185
+ // Themes belong to the terminal the user sits at, so that terminal runs it, even when attached.
186
+ runner: 'client',
187
+ surfaces: ['terminal'],
182
188
  };
183
189
  }
184
190
 
@@ -190,7 +196,9 @@ export class ThemeCommandSource implements ICommandSource {
190
196
  }
191
197
  }
192
198
 
193
- export function createThemeCommandModule(catalogue: IThemeCataloguePort): ICommandModule {
199
+ export function createThemeCommandModule(
200
+ catalogue: IThemeCataloguePort | undefined,
201
+ ): ICommandModule {
194
202
  const entry = createThemeCommandEntry();
195
203
  const command: ISystemCommand = {
196
204
  name: entry.name,
@@ -200,6 +208,8 @@ export function createThemeCommandModule(catalogue: IThemeCataloguePort): IComma
200
208
  requiresPermission: false,
201
209
  userInvocable: true,
202
210
  modelInvocable: false,
211
+ runner: entry.runner,
212
+ surfaces: entry.surfaces,
203
213
  lifecycle: 'inline',
204
214
  execute: (_context, args) => executeThemeCommand(catalogue, args),
205
215
  };
@@ -21,8 +21,8 @@ afterEach(async () => {
21
21
  });
22
22
 
23
23
  describe('user-local command', () => {
24
- it('does not advertise a specific product from the neutral command entry', () => {
25
- const module = createUserLocalCommandModule('/tmp/selected-storage');
24
+ it('does not advertise a specific product from the neutral command entry', async () => {
25
+ const module = createUserLocalCommandModule(await createTempRoot('robota-selected-storage-'));
26
26
  expect(module.commandSources?.[0]?.getCommands()[0]?.description).not.toMatch(/Robota/i);
27
27
  });
28
28
 
@@ -1,125 +0,0 @@
1
- /** BEHAVIOR-2437 TC-02 — `/git diff`: the closed grammar and the exact argv sequence per branch. */
2
- import { describe, expect, it } from 'vitest';
3
-
4
- import { executeGitDiff, gitDiffArgv, parseGitDiffArgs, GIT_DIFF_USAGE } from '../git-diff.js';
5
-
6
- import { exited, fakeGitPort } from './fake-git-port.js';
7
-
8
- const revParse = (rev: string): string =>
9
- `rev-parse --verify --quiet --end-of-options ${rev}^{commit}`;
10
-
11
- describe('parseGitDiffArgs', () => {
12
- it('accepts the four forms and the path list', () => {
13
- expect(parseGitDiffArgs([])).toEqual({
14
- ok: true,
15
- args: { target: { kind: 'worktree' }, paths: [] },
16
- });
17
- expect(parseGitDiffArgs(['--staged'])).toMatchObject({
18
- ok: true,
19
- args: { target: { kind: 'staged' } },
20
- });
21
- expect(parseGitDiffArgs(['HEAD~1'])).toMatchObject({
22
- ok: true,
23
- args: { target: { kind: 'revision', revision: 'HEAD~1' } },
24
- });
25
- expect(parseGitDiffArgs(['main..feature', '--', 'p', 'q'])).toEqual({
26
- ok: true,
27
- args: { target: { kind: 'range', from: 'main', to: 'feature' }, paths: ['p', 'q'] },
28
- });
29
- });
30
-
31
- it('refuses every other dash token with the usage line', () => {
32
- for (const token of ['--output=/tmp/x', '-p', '--cached', '-U3']) {
33
- const parsed = parseGitDiffArgs([token]);
34
- expect(parsed.ok).toBe(false);
35
- if (!parsed.ok) {
36
- expect(parsed.message).toContain(token);
37
- expect(parsed.message).toContain(GIT_DIFF_USAGE);
38
- }
39
- }
40
- });
41
-
42
- it('refuses a malformed range, a bare `--`, and extra positionals', () => {
43
- expect(parseGitDiffArgs(['a..'])).toMatchObject({ ok: false });
44
- expect(parseGitDiffArgs(['a...b'])).toMatchObject({ ok: false });
45
- expect(parseGitDiffArgs(['--'])).toMatchObject({ ok: false });
46
- expect(parseGitDiffArgs(['a', 'b'])).toMatchObject({ ok: false });
47
- });
48
- });
49
-
50
- describe('gitDiffArgv', () => {
51
- it('places --end-of-options before revisions and -- before paths', () => {
52
- expect(gitDiffArgv({ target: { kind: 'worktree' }, paths: [] })).toEqual(['diff']);
53
- expect(gitDiffArgv({ target: { kind: 'staged' }, paths: [] })).toEqual(['diff', '--staged']);
54
- expect(gitDiffArgv({ target: { kind: 'revision', revision: '-p' }, paths: ['-p'] })).toEqual([
55
- 'diff',
56
- '--end-of-options',
57
- '-p',
58
- '--',
59
- '-p',
60
- ]);
61
- expect(
62
- gitDiffArgv({ target: { kind: 'range', from: 'a', to: 'b' }, paths: ['p', 'q'] }),
63
- ).toEqual(['diff', '--end-of-options', 'a..b', '--', 'p', 'q']);
64
- });
65
- });
66
-
67
- describe('executeGitDiff', () => {
68
- it('bare and --staged run one diff call each', async () => {
69
- const port = fakeGitPort({ diff: exited('+x\n'), 'diff --staged': exited('') });
70
- expect((await executeGitDiff(port, '/r', '')).message).toBe('+x');
71
- expect((await executeGitDiff(port, '/r', '--staged')).message).toBe('No differences.');
72
- expect(port.calls).toEqual([['diff'], ['diff', '--staged']]);
73
- });
74
-
75
- it('verifies one revision, then diffs, with a trailing path list', async () => {
76
- const port = fakeGitPort({
77
- [revParse('HEAD~1')]: exited('abc\n'),
78
- 'diff --end-of-options HEAD~1 -- p q': exited('+y\n'),
79
- });
80
- const result = await executeGitDiff(port, '/r', 'HEAD~1 -- p q');
81
- expect(result.success).toBe(true);
82
- expect(port.calls).toEqual([
83
- ['rev-parse', '--verify', '--quiet', '--end-of-options', 'HEAD~1^{commit}'],
84
- ['diff', '--end-of-options', 'HEAD~1', '--', 'p', 'q'],
85
- ]);
86
- });
87
-
88
- it('verifies both ends of a range before one diff call', async () => {
89
- const port = fakeGitPort({
90
- [revParse('a')]: exited('1\n'),
91
- [revParse('b')]: exited('2\n'),
92
- 'diff --end-of-options a..b': exited(''),
93
- });
94
- await executeGitDiff(port, '/r', 'a..b');
95
- expect(port.calls.map((c) => c.join(' '))).toEqual([
96
- revParse('a'),
97
- revParse('b'),
98
- 'diff --end-of-options a..b',
99
- ]);
100
- });
101
-
102
- it('refuses an unknown revision by name with no diff call', async () => {
103
- const port = fakeGitPort({ [revParse('nosuchrev')]: exited('', 1) });
104
- const result = await executeGitDiff(port, '/r', 'nosuchrev');
105
- expect(result.success).toBe(false);
106
- expect(result.message).toContain('nosuchrev');
107
- expect(port.calls).toHaveLength(1);
108
- });
109
-
110
- it('refuses a flag with the usage line and no call at all', async () => {
111
- const port = fakeGitPort({});
112
- const result = await executeGitDiff(port, '/r', '--output=/tmp/x');
113
- expect(result.success).toBe(false);
114
- expect(result.message).toContain('--output=/tmp/x');
115
- expect(result.message).toContain('--staged');
116
- expect(port.calls).toEqual([]);
117
- });
118
-
119
- it('reports a diff failure with git’s stderr', async () => {
120
- const port = fakeGitPort({ diff: exited('', 129, 'fatal: bad\n') });
121
- const result = await executeGitDiff(port, '/r', '');
122
- expect(result.success).toBe(false);
123
- expect(result.message).toContain('fatal: bad');
124
- });
125
- });