@robota-sdk/agent-command 3.0.0-beta.100

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 (251) hide show
  1. package/CHANGELOG.md +1351 -0
  2. package/LICENSE +661 -0
  3. package/README.md +100 -0
  4. package/dist/node/index.cjs +121 -0
  5. package/dist/node/index.d.cts +1053 -0
  6. package/dist/node/index.d.cts.map +1 -0
  7. package/dist/node/index.d.ts +1053 -0
  8. package/dist/node/index.d.ts.map +1 -0
  9. package/dist/node/index.js +122 -0
  10. package/dist/node/index.js.map +1 -0
  11. package/package.json +83 -0
  12. package/src/advisor/advisor-command-module.ts +47 -0
  13. package/src/advisor/advisor-command.test.ts +102 -0
  14. package/src/advisor/advisor-command.ts +55 -0
  15. package/src/advisor/advisor-cost-functional.test.ts +92 -0
  16. package/src/advisor/index.ts +6 -0
  17. package/src/agent/__tests__/agent-command.test.ts +597 -0
  18. package/src/agent/agent-command-module.ts +89 -0
  19. package/src/agent/agent-command-parser.ts +180 -0
  20. package/src/agent/agent-command.ts +255 -0
  21. package/src/agent/index.ts +7 -0
  22. package/src/background/__tests__/background-command-module.test.ts +261 -0
  23. package/src/background/background-command-module.ts +52 -0
  24. package/src/background/background-command.ts +65 -0
  25. package/src/background/index.ts +6 -0
  26. package/src/command-module-utils.test.ts +37 -0
  27. package/src/command-module-utils.ts +35 -0
  28. package/src/compact/__tests__/compact-command-module.test.ts +181 -0
  29. package/src/compact/compact-command-module.ts +59 -0
  30. package/src/compact/compact-command.ts +33 -0
  31. package/src/compact/index.ts +6 -0
  32. package/src/context/__tests__/context-command-module.test.ts +502 -0
  33. package/src/context/auto-compact-format.ts +25 -0
  34. package/src/context/context-breakdown.ts +295 -0
  35. package/src/context/context-command-module.ts +82 -0
  36. package/src/context/context-command.ts +291 -0
  37. package/src/context/index.ts +6 -0
  38. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +121 -0
  39. package/src/default/__tests__/default-command-modules.test.ts +192 -0
  40. package/src/default/__tests__/model-exposure.test.ts +269 -0
  41. package/src/default/__tests__/org-policy-forwarding.test.ts +106 -0
  42. package/src/default/__tests__/provider-environment-forwarding.test.ts +98 -0
  43. package/src/default/default-command-modules.ts +247 -0
  44. package/src/default/index.ts +5 -0
  45. package/src/devices/__tests__/devices-command-module.test.ts +299 -0
  46. package/src/devices/devices-command-module.ts +308 -0
  47. package/src/devices/devices-command-port.ts +143 -0
  48. package/src/devices/index.ts +14 -0
  49. package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
  50. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
  51. package/src/doctor/__tests__/doctor-fixture.ts +146 -0
  52. package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
  53. package/src/doctor/__tests__/doctor-runner.test.ts +484 -0
  54. package/src/doctor/doctor-command-module.ts +132 -0
  55. package/src/doctor/doctor-extensions-probe.ts +323 -0
  56. package/src/doctor/doctor-node-deps.ts +88 -0
  57. package/src/doctor/doctor-redaction.ts +99 -0
  58. package/src/doctor/doctor-render.ts +70 -0
  59. package/src/doctor/doctor-repair.ts +136 -0
  60. package/src/doctor/doctor-runner.ts +122 -0
  61. package/src/doctor/doctor-settings-probe.ts +239 -0
  62. package/src/doctor/doctor-storage-probe.ts +187 -0
  63. package/src/doctor/doctor-types.ts +109 -0
  64. package/src/doctor/index.ts +32 -0
  65. package/src/editor/__tests__/editor-command-functional.test.ts +124 -0
  66. package/src/editor/editor-command-module.ts +53 -0
  67. package/src/editor/editor-command.ts +58 -0
  68. package/src/editor/index.ts +7 -0
  69. package/src/editor/resolve-editor.ts +21 -0
  70. package/src/effort/effort-command-module.ts +51 -0
  71. package/src/effort/effort-command.test.ts +203 -0
  72. package/src/effort/effort-command.ts +136 -0
  73. package/src/effort/index.ts +6 -0
  74. package/src/events/__tests__/events-command.test.ts +73 -0
  75. package/src/events/events-command-module.ts +49 -0
  76. package/src/events/events-command.ts +69 -0
  77. package/src/events/index.ts +2 -0
  78. package/src/exit/__tests__/exit-command-module.test.ts +61 -0
  79. package/src/exit/exit-command-module.ts +47 -0
  80. package/src/exit/exit-command.ts +26 -0
  81. package/src/exit/index.ts +6 -0
  82. package/src/fork/__tests__/fork-command.test.ts +184 -0
  83. package/src/fork/fork-command-module.ts +61 -0
  84. package/src/fork/fork-command.ts +162 -0
  85. package/src/fork/index.ts +8 -0
  86. package/src/git/__tests__/fake-git-port.ts +35 -0
  87. package/src/git/__tests__/git-command-module.test.ts +111 -0
  88. package/src/git/__tests__/git-commit.test.ts +185 -0
  89. package/src/git/git-command-module.ts +138 -0
  90. package/src/git/git-commit.ts +254 -0
  91. package/src/git/index.ts +20 -0
  92. package/src/goal/__tests__/goal-command.test.ts +80 -0
  93. package/src/goal/goal-command-module.ts +49 -0
  94. package/src/goal/goal-command.ts +69 -0
  95. package/src/goal/index.ts +6 -0
  96. package/src/handoff/__tests__/handoff-command-module.test.ts +25 -0
  97. package/src/handoff/__tests__/handoff-command.test.ts +218 -0
  98. package/src/handoff/handoff-command-module.ts +49 -0
  99. package/src/handoff/handoff-command.ts +187 -0
  100. package/src/handoff/index.ts +6 -0
  101. package/src/help/__tests__/help-command-module.test.ts +134 -0
  102. package/src/help/__tests__/help-command.test.ts +130 -0
  103. package/src/help/help-command-module.ts +47 -0
  104. package/src/help/help-command.ts +11 -0
  105. package/src/help/index.ts +6 -0
  106. package/src/index.ts +163 -0
  107. package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
  108. package/src/keybindings/index.ts +6 -0
  109. package/src/keybindings/keybindings-command-module.ts +88 -0
  110. package/src/language/__tests__/language-command-module.test.ts +126 -0
  111. package/src/language/index.ts +6 -0
  112. package/src/language/language-command-module.ts +55 -0
  113. package/src/language/language-command.ts +52 -0
  114. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +895 -0
  115. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +242 -0
  116. package/src/mcp-activation/__tests__/mcp-skill-command.test.ts +141 -0
  117. package/src/mcp-activation/index.ts +12 -0
  118. package/src/mcp-activation/mcp-activation-command-module.ts +123 -0
  119. package/src/mcp-activation/mcp-activation-command.ts +662 -0
  120. package/src/mcp-activation/mcp-model-notice.ts +99 -0
  121. package/src/mcp-activation/mcp-model-status.ts +104 -0
  122. package/src/memory/__tests__/memory-command-module.test.ts +476 -0
  123. package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
  124. package/src/memory/index.ts +6 -0
  125. package/src/memory/memory-command-module.ts +77 -0
  126. package/src/memory/memory-command.ts +326 -0
  127. package/src/mode/__tests__/mode-command-module.test.ts +230 -0
  128. package/src/mode/index.ts +6 -0
  129. package/src/mode/mode-command-module.ts +55 -0
  130. package/src/mode/mode-command.ts +67 -0
  131. package/src/model/__tests__/model-command-module.test.ts +80 -0
  132. package/src/model/__tests__/model-command.test.ts +265 -0
  133. package/src/model/index.ts +6 -0
  134. package/src/model/model-command-module.ts +56 -0
  135. package/src/model/model-command.ts +249 -0
  136. package/src/output-style/__tests__/output-style-command.test.ts +96 -0
  137. package/src/output-style/index.ts +6 -0
  138. package/src/output-style/output-style-command-module.ts +143 -0
  139. package/src/peers/__tests__/peers-command-module.test.ts +24 -0
  140. package/src/peers/__tests__/peers-command.test.ts +399 -0
  141. package/src/peers/index.ts +9 -0
  142. package/src/peers/peers-command-module.ts +49 -0
  143. package/src/peers/peers-command.ts +220 -0
  144. package/src/permissions/__tests__/permissions-command-module.test.ts +276 -0
  145. package/src/permissions/index.ts +6 -0
  146. package/src/permissions/permissions-command-module.ts +55 -0
  147. package/src/permissions/permissions-command.ts +89 -0
  148. package/src/plan/__tests__/plan-command.test.ts +129 -0
  149. package/src/plan/index.ts +9 -0
  150. package/src/plan/plan-command-module.ts +49 -0
  151. package/src/plan/plan-command.ts +92 -0
  152. package/src/plugin/__tests__/plugin-active-turn.test.ts +100 -0
  153. package/src/plugin/__tests__/plugin-command-module.test.ts +300 -0
  154. package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
  155. package/src/plugin/index.ts +7 -0
  156. package/src/plugin/plugin-command-module.ts +85 -0
  157. package/src/plugin/plugin-command.ts +271 -0
  158. package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
  159. package/src/preset/__tests__/preset-command-module.test.ts +220 -0
  160. package/src/preset/index.ts +6 -0
  161. package/src/preset/preset-command-module.ts +80 -0
  162. package/src/preset/preset-command.ts +148 -0
  163. package/src/provider/__tests__/optional-provider-credentials.test.ts +130 -0
  164. package/src/provider/__tests__/org-policy.test.ts +174 -0
  165. package/src/provider/__tests__/provider-command-module.test.ts +884 -0
  166. package/src/provider/__tests__/provider-connection-setup.test.ts +567 -0
  167. package/src/provider/__tests__/provider-reference-switch.test.ts +113 -0
  168. package/src/provider/__tests__/provider-setup-flow.test.ts +244 -0
  169. package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
  170. package/src/provider/__tests__/scripted-interaction.ts +40 -0
  171. package/src/provider/index.ts +32 -0
  172. package/src/provider/provider-command-execution.ts +235 -0
  173. package/src/provider/provider-command-module.ts +82 -0
  174. package/src/provider/provider-command-profile-lifecycle.ts +239 -0
  175. package/src/provider/provider-command-profile-operations.ts +218 -0
  176. package/src/provider/provider-command-profile.ts +116 -0
  177. package/src/provider/provider-command-setup.ts +79 -0
  178. package/src/provider/provider-connection-setup.ts +287 -0
  179. package/src/provider/provider-connection-validation.ts +38 -0
  180. package/src/provider/provider-onboarding.ts +73 -0
  181. package/src/provider/provider-prompt-interaction.ts +28 -0
  182. package/src/provider/provider-setup-ask.ts +67 -0
  183. package/src/provider/provider-setup-flow.ts +326 -0
  184. package/src/provider/provider-startup.ts +225 -0
  185. package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
  186. package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
  187. package/src/remote-control/index.ts +8 -0
  188. package/src/remote-control/remote-control-command-module.ts +61 -0
  189. package/src/remote-control/remote-control-command.ts +138 -0
  190. package/src/reset/__tests__/reset-command-module.test.ts +63 -0
  191. package/src/reset/index.ts +2 -0
  192. package/src/reset/reset-command-module.ts +47 -0
  193. package/src/reset/reset-command.ts +10 -0
  194. package/src/rewind/__tests__/rewind-command-module.test.ts +287 -0
  195. package/src/rewind/index.ts +2 -0
  196. package/src/rewind/rewind-command-module.ts +56 -0
  197. package/src/rewind/rewind-command.ts +279 -0
  198. package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
  199. package/src/sandbox/index.ts +2 -0
  200. package/src/sandbox/sandbox-command-module.ts +56 -0
  201. package/src/sandbox/sandbox-command.ts +94 -0
  202. package/src/schedule/__tests__/loop-command-functional.test.ts +399 -0
  203. package/src/schedule/__tests__/loop-command.test.ts +594 -0
  204. package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
  205. package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
  206. package/src/schedule/__tests__/schedule-command.test.ts +247 -0
  207. package/src/schedule/__tests__/schedule-redos.test.ts +114 -0
  208. package/src/schedule/index.ts +12 -0
  209. package/src/schedule/loop-command.ts +427 -0
  210. package/src/schedule/loop-jitter.ts +41 -0
  211. package/src/schedule/schedule-command-module.ts +184 -0
  212. package/src/schedule/schedule-command.ts +161 -0
  213. package/src/schedule/schedule-spec-parser.ts +70 -0
  214. package/src/session/__tests__/model-pricing.test.ts +66 -0
  215. package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
  216. package/src/session/__tests__/session-command-module.test.ts +589 -0
  217. package/src/session/index.ts +17 -0
  218. package/src/session/model-pricing.ts +26 -0
  219. package/src/session/session-command-module.ts +197 -0
  220. package/src/session/session-command.ts +272 -0
  221. package/src/settings/index.ts +7 -0
  222. package/src/settings/settings-command-module.ts +47 -0
  223. package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
  224. package/src/shell/__tests__/shell-command-functional.test.ts +118 -0
  225. package/src/shell/index.ts +8 -0
  226. package/src/shell/resolve-shell.ts +25 -0
  227. package/src/shell/shell-command-module.ts +53 -0
  228. package/src/shell/shell-command.ts +48 -0
  229. package/src/shell/spawn-inherited.ts +32 -0
  230. package/src/skills/__tests__/skill-read-command.test.ts +64 -0
  231. package/src/skills/__tests__/skills-command-module.test.ts +200 -0
  232. package/src/skills/index.ts +7 -0
  233. package/src/skills/skill-read-command.ts +60 -0
  234. package/src/skills/skills-command-module.ts +76 -0
  235. package/src/skills/skills-command.ts +108 -0
  236. package/src/statusline/__tests__/statusline-command-module.test.ts +96 -0
  237. package/src/statusline/index.ts +6 -0
  238. package/src/statusline/statusline-command-module.ts +55 -0
  239. package/src/statusline/statusline-command.ts +80 -0
  240. package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
  241. package/src/terminal-client/index.ts +5 -0
  242. package/src/terminal-client/terminal-client-commands.ts +109 -0
  243. package/src/theme/__tests__/theme-command.test.ts +204 -0
  244. package/src/theme/index.ts +12 -0
  245. package/src/theme/theme-command-module.ts +221 -0
  246. package/src/user-local/__tests__/user-local-command.test.ts +180 -0
  247. package/src/user-local/index.ts +13 -0
  248. package/src/user-local/user-local-command-constants.ts +5 -0
  249. package/src/user-local/user-local-command-module.ts +65 -0
  250. package/src/user-local/user-local-command.ts +214 -0
  251. package/src/user-local/user-local-memory-command.ts +160 -0
@@ -0,0 +1,204 @@
1
+ /**
2
+ * SCREEN-2002 TC-09 — `/theme`.
3
+ *
4
+ * Three properties carry this command. It emits EXACTLY ONE patch per invocation, so a submission
5
+ * cannot half-apply. An unknown id writes NOTHING — not the theme, and not the toggles submitted
6
+ * beside it — because a user who mistyped an id did not also ask for the rest. And `motion on`
7
+ * means `reducedMotion: false`: the inversion is the easy thing to read backwards, and a user
8
+ * asking for motion and getting stillness would look like the command doing nothing at all.
9
+ */
10
+ import { describe, expect, it } from 'vitest';
11
+
12
+ import { executeThemeCommand } from '../theme-command-module.js';
13
+
14
+ import type { TAppearanceSettingsPatch } from '@robota-sdk/agent-framework';
15
+ import type {
16
+ IThemeAppearanceState,
17
+ IThemeCatalogueEntry,
18
+ IThemeCataloguePort,
19
+ TReducedMotionOverride,
20
+ } from '@robota-sdk/agent-interface-command';
21
+
22
+ const THEMES: IThemeCatalogueEntry[] = [
23
+ { id: 'dark', name: 'Dark', appearance: 'dark', source: 'built-in' },
24
+ { id: 'light', name: 'Light', appearance: 'light', source: 'built-in' },
25
+ {
26
+ id: 'dark-daltonized',
27
+ name: 'Dark (daltonized)',
28
+ appearance: 'dark',
29
+ source: 'built-in',
30
+ },
31
+ ];
32
+
33
+ function catalogue(
34
+ state: Partial<IThemeAppearanceState['settings']> = {},
35
+ reducedMotionOverride?: TReducedMotionOverride,
36
+ reducedMotionForRun?: boolean,
37
+ ): IThemeCataloguePort {
38
+ const settings = {
39
+ theme: 'dark',
40
+ syntaxHighlighting: true,
41
+ reducedMotion: false,
42
+ ...state,
43
+ };
44
+ return {
45
+ listThemes: () => THEMES,
46
+ getTheme: (id) => THEMES.find((theme) => theme.id === id),
47
+ getAppearance: () => ({
48
+ settings,
49
+ ...(reducedMotionOverride === undefined
50
+ ? {}
51
+ : {
52
+ reducedMotionPin: {
53
+ tier: reducedMotionOverride,
54
+ reducedMotion: reducedMotionForRun ?? true,
55
+ },
56
+ }),
57
+ }),
58
+ };
59
+ }
60
+
61
+ function patchOf(result: { hostActions?: readonly unknown[] }): TAppearanceSettingsPatch {
62
+ expect(result.hostActions).toHaveLength(1);
63
+ const action = result.hostActions?.[0] as {
64
+ type: string;
65
+ patch: TAppearanceSettingsPatch;
66
+ };
67
+ expect(action.type).toBe('appearance-settings-patch');
68
+ return action.patch;
69
+ }
70
+
71
+ describe('/theme without a catalogue (SCREEN-2002 TC-09)', () => {
72
+ it('reports its unavailability instead of failing silently or writing anything', () => {
73
+ const result = executeThemeCommand(undefined, 'light');
74
+
75
+ expect(result.success).toBe(false);
76
+ expect(result.message).toMatch(/Run \/theme there/);
77
+ expect(result.hostActions).toBeUndefined();
78
+ });
79
+ });
80
+
81
+ describe('/theme with no argument (SCREEN-2002 TC-09)', () => {
82
+ it('asks the surface for the picker rather than changing anything', () => {
83
+ const result = executeThemeCommand(catalogue(), ' ');
84
+
85
+ expect(result.success).toBe(true);
86
+ expect(result.uiIntents).toEqual([{ type: 'show-theme-picker' }]);
87
+ expect(result.hostActions).toBeUndefined();
88
+ });
89
+ });
90
+
91
+ describe('/theme list (SCREEN-2002 TC-09)', () => {
92
+ it('marks the active theme and names each source, and writes nothing', () => {
93
+ const result = executeThemeCommand(catalogue({ theme: 'light' }), 'list');
94
+
95
+ expect(result.success).toBe(true);
96
+ expect(result.message).toContain('* light — Light (light, built-in)');
97
+ expect(result.message).toContain(' dark — Dark (dark, built-in)');
98
+ expect(result.message).toContain('syntax highlighting: on');
99
+ expect(result.message).toContain('reduced motion: off');
100
+ expect(result.hostActions).toBeUndefined();
101
+ });
102
+
103
+ it('reports what THIS RUN does and what is saved, never one in place of the other', () => {
104
+ // SCREEN-2002 TC-13 found this at the PTY level: reporting the persisted value beside the tier
105
+ // printed `reduced motion: off (… pinned by flag)` on a `--reduced-motion` run — a line that
106
+ // contradicts itself, and that answers "is motion reduced right now" with the wrong word.
107
+ const pinnedOn = executeThemeCommand(catalogue({ reducedMotion: false }, 'flag', true), 'list');
108
+ expect(pinnedOn.message).toContain(
109
+ 'reduced motion: on for this run (pinned by flag; saved off)',
110
+ );
111
+
112
+ // `--no-reduced-motion` is an override too, and it pins the opposite value: a surface that
113
+ // renders the TIER alone cannot tell these two runs apart.
114
+ const pinnedOff = executeThemeCommand(
115
+ catalogue({ reducedMotion: true }, 'flag', false),
116
+ 'list',
117
+ );
118
+ expect(pinnedOff.message).toContain(
119
+ 'reduced motion: off for this run (pinned by flag; saved on)',
120
+ );
121
+ });
122
+ });
123
+
124
+ describe('/theme <id> (SCREEN-2002 TC-09)', () => {
125
+ it('emits exactly one patch naming the theme', () => {
126
+ const result = executeThemeCommand(catalogue(), 'dark-daltonized');
127
+
128
+ expect(result.success).toBe(true);
129
+ expect(patchOf(result)).toEqual({ theme: 'dark-daltonized' });
130
+ expect(result.message).toContain('Dark (daltonized)');
131
+ });
132
+
133
+ it('refuses an unknown id, names the catalogue, and writes NOTHING', () => {
134
+ const result = executeThemeCommand(catalogue(), 'solarized');
135
+
136
+ expect(result.success).toBe(false);
137
+ expect(result.message).toContain('Unknown theme "solarized"');
138
+ expect(result.message).toContain('dark, light, dark-daltonized');
139
+ expect(result.hostActions).toBeUndefined();
140
+ });
141
+
142
+ it('does not apply the toggles submitted beside an unknown id', () => {
143
+ const result = executeThemeCommand(catalogue(), 'solarized syntax off');
144
+
145
+ expect(result.success).toBe(false);
146
+ expect(result.hostActions).toBeUndefined();
147
+ });
148
+
149
+ it('carries the theme and its toggles in ONE patch', () => {
150
+ const result = executeThemeCommand(catalogue(), 'light syntax off motion off');
151
+
152
+ expect(patchOf(result)).toEqual({
153
+ theme: 'light',
154
+ syntaxHighlighting: false,
155
+ reducedMotion: true,
156
+ });
157
+ });
158
+ });
159
+
160
+ describe('the motion inversion (SCREEN-2002 TC-09)', () => {
161
+ it('reads `motion on` as animation, which is reducedMotion FALSE', () => {
162
+ expect(patchOf(executeThemeCommand(catalogue({ reducedMotion: true }), 'motion on'))).toEqual({
163
+ reducedMotion: false,
164
+ });
165
+ });
166
+
167
+ it('reads `motion off` as stillness, which is reducedMotion TRUE', () => {
168
+ expect(patchOf(executeThemeCommand(catalogue(), 'motion off'))).toEqual({
169
+ reducedMotion: true,
170
+ });
171
+ });
172
+
173
+ it('reports the change in the user s words, not the settings key s', () => {
174
+ expect(executeThemeCommand(catalogue(), 'motion off').message).toContain('motion off');
175
+ });
176
+
177
+ it('persists the setting but says this run keeps what pinned it', () => {
178
+ const result = executeThemeCommand(catalogue({ reducedMotion: true }, 'flag'), 'motion on');
179
+
180
+ expect(patchOf(result)).toEqual({ reducedMotion: false });
181
+ expect(result.message).toContain('Saved, but this run keeps reduced motion pinned by flag');
182
+ });
183
+ });
184
+
185
+ describe('/theme syntax (SCREEN-2002 TC-09)', () => {
186
+ it('toggles highlighting without touching the theme', () => {
187
+ expect(patchOf(executeThemeCommand(catalogue(), 'syntax off'))).toEqual({
188
+ syntaxHighlighting: false,
189
+ });
190
+ });
191
+ });
192
+
193
+ describe('a malformed argument (SCREEN-2002 TC-09)', () => {
194
+ it.each(['motion', 'syntax', 'motion yes', 'syntax enable', 'light motion', 'light nonsense on'])(
195
+ 'refuses %o with the usage line rather than guessing',
196
+ (args) => {
197
+ const result = executeThemeCommand(catalogue(), args);
198
+
199
+ expect(result.success).toBe(false);
200
+ expect(result.message).toContain('Usage: /theme');
201
+ expect(result.hostActions).toBeUndefined();
202
+ },
203
+ );
204
+ });
@@ -0,0 +1,12 @@
1
+ export {
2
+ createThemeCommandEntry,
3
+ createThemeCommandModule,
4
+ executeThemeCommand,
5
+ ThemeCommandSource,
6
+ } from './theme-command-module.js';
7
+ export type {
8
+ IThemeAppearanceState,
9
+ IThemeCatalogueEntry,
10
+ IThemeCataloguePort,
11
+ TReducedMotionOverride,
12
+ } from '@robota-sdk/agent-interface-command';
@@ -0,0 +1,221 @@
1
+ import type {
2
+ ICommandModule,
3
+ ISystemCommand,
4
+ TAppearanceSettingsPatch,
5
+ } from '@robota-sdk/agent-framework';
6
+ import type {
7
+ ICommand,
8
+ ICommandResult,
9
+ ICommandSource,
10
+ IThemeAppearanceState,
11
+ IThemeCatalogueEntry,
12
+ IThemeCataloguePort,
13
+ } from '@robota-sdk/agent-interface-command';
14
+
15
+ const THEME_DESCRIPTION =
16
+ 'List, preview or switch the terminal colour theme, and toggle syntax highlighting and motion';
17
+ const THEME_ARGUMENT_HINT = 'list | <theme-id> | syntax on|off | motion on|off';
18
+
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 are available in an interactive terminal. Run /theme there.';
23
+
24
+ function formatToggle(value: boolean): string {
25
+ return value ? 'on' : 'off';
26
+ }
27
+
28
+ function formatCatalogue(catalogue: IThemeCataloguePort): string {
29
+ const { settings, reducedMotionPin } = catalogue.getAppearance();
30
+ const rows = catalogue.listThemes().map((theme) => {
31
+ const marker = theme.id === settings.theme ? '* ' : ' ';
32
+ return `${marker}${theme.id} — ${theme.name} (${theme.appearance}, ${theme.source})`;
33
+ });
34
+ // Two facts, never one: what this run does, and what is saved. Reporting the SAVED value beside
35
+ // the tier reads as a contradiction on a `--no-reduced-motion` run over a `true` setting, and as
36
+ // an outright wrong answer to "is motion reduced right now".
37
+ const motion = reducedMotionPin
38
+ ? `${formatToggle(reducedMotionPin.reducedMotion)} for this run (pinned by ${reducedMotionPin.tier}; saved ${formatToggle(settings.reducedMotion)})`
39
+ : formatToggle(settings.reducedMotion);
40
+ return [
41
+ 'Available themes:',
42
+ ...rows,
43
+ '',
44
+ `syntax highlighting: ${formatToggle(settings.syntaxHighlighting)}`,
45
+ `reduced motion: ${motion}`,
46
+ ].join('\n');
47
+ }
48
+
49
+ function listResult(catalogue: IThemeCataloguePort): ICommandResult {
50
+ return {
51
+ success: true,
52
+ message: formatCatalogue(catalogue),
53
+ data: {
54
+ themes: catalogue.listThemes().map((theme) => ({ ...theme })),
55
+ appearance: { ...catalogue.getAppearance().settings },
56
+ },
57
+ };
58
+ }
59
+
60
+ function unknownThemeResult(id: string, catalogue: IThemeCataloguePort): ICommandResult {
61
+ const known = catalogue
62
+ .listThemes()
63
+ .map((theme) => theme.id)
64
+ .join(', ');
65
+ return {
66
+ success: false,
67
+ message: `Unknown theme "${id}". Available: ${known || '(none)'}`,
68
+ };
69
+ }
70
+
71
+ /** `on` / `off`, and nothing else — a typo must not read as its opposite. */
72
+ function parseToggle(word: string | undefined): boolean | undefined {
73
+ if (word === 'on') return true;
74
+ if (word === 'off') return false;
75
+ return undefined;
76
+ }
77
+
78
+ interface IParsedThemeArgs {
79
+ readonly patch?: TAppearanceSettingsPatch;
80
+ readonly themeId?: string;
81
+ readonly error?: string;
82
+ }
83
+
84
+ /**
85
+ * `/theme <id> [syntax on|off] [motion on|off]`, or a bare `syntax`/`motion` toggle. The theme id is
86
+ * NOT resolved here — parsing answers what was asked for, and the caller decides whether the
87
+ * catalogue holds it, so an unknown id is reported with the catalogue rather than as a parse error.
88
+ */
89
+ function parseThemeArgs(tokens: readonly string[]): IParsedThemeArgs {
90
+ const patch: TAppearanceSettingsPatch = {};
91
+ let index = 0;
92
+ let themeId: string | undefined;
93
+ if (tokens[0] !== 'syntax' && tokens[0] !== 'motion') {
94
+ themeId = tokens[0];
95
+ index = 1;
96
+ }
97
+ while (index < tokens.length) {
98
+ const key = tokens[index];
99
+ const value = parseToggle(tokens[index + 1]);
100
+ if ((key !== 'syntax' && key !== 'motion') || value === undefined) {
101
+ return { error: `Usage: /theme ${THEME_ARGUMENT_HINT}` };
102
+ }
103
+ if (key === 'syntax') patch.syntaxHighlighting = value;
104
+ else patch.reducedMotion = !value;
105
+ index += 2;
106
+ }
107
+ return { patch, ...(themeId === undefined ? {} : { themeId }) };
108
+ }
109
+
110
+ /**
111
+ * `motion on` means "animate", which is `reducedMotion: false`. The inversion lives in one place
112
+ * precisely because reading it backwards is the easy mistake — a user asking for motion and getting
113
+ * stillness would look like the command doing nothing.
114
+ */
115
+ function describeChange(
116
+ patch: TAppearanceSettingsPatch,
117
+ theme: IThemeCatalogueEntry | undefined,
118
+ state: IThemeAppearanceState,
119
+ ): string {
120
+ const parts: string[] = [];
121
+ if (theme) parts.push(`theme ${theme.name}`);
122
+ if (patch.syntaxHighlighting !== undefined) {
123
+ parts.push(`syntax highlighting ${formatToggle(patch.syntaxHighlighting)}`);
124
+ }
125
+ if (patch.reducedMotion !== undefined) {
126
+ parts.push(`motion ${formatToggle(!patch.reducedMotion)}`);
127
+ }
128
+ const applied = parts.length > 0 ? `Applied: ${parts.join(', ')}.` : 'Nothing to change.';
129
+ // The pin is reported, never silently obeyed or silently overridden: the setting IS persisted and
130
+ // takes effect next run, while this run keeps what pinned it.
131
+ if (patch.reducedMotion !== undefined && state.reducedMotionPin) {
132
+ return `${applied} Saved, but this run keeps reduced motion pinned by ${state.reducedMotionPin.tier}.`;
133
+ }
134
+ return applied;
135
+ }
136
+
137
+ export function executeThemeCommand(
138
+ catalogue: IThemeCataloguePort | undefined,
139
+ args: string,
140
+ ): ICommandResult {
141
+ if (!catalogue) return { success: false, message: THEME_UNAVAILABLE };
142
+
143
+ const tokens = args.trim().split(/\s+/u).filter(Boolean);
144
+ if (tokens.length === 0) {
145
+ return {
146
+ success: true,
147
+ message: 'Opening the theme picker...',
148
+ uiIntents: [{ type: 'show-theme-picker' }],
149
+ };
150
+ }
151
+ if (tokens.length === 1 && tokens[0] === 'list') return listResult(catalogue);
152
+
153
+ const parsed = parseThemeArgs(tokens);
154
+ if (parsed.error !== undefined || !parsed.patch) {
155
+ return { success: false, message: parsed.error ?? `Usage: /theme ${THEME_ARGUMENT_HINT}` };
156
+ }
157
+
158
+ const patch = { ...parsed.patch };
159
+ let theme: IThemeCatalogueEntry | undefined;
160
+ if (parsed.themeId !== undefined) {
161
+ theme = catalogue.getTheme(parsed.themeId);
162
+ // An unknown id writes NOTHING — not the theme, and not the toggles submitted beside it.
163
+ if (!theme) return unknownThemeResult(parsed.themeId, catalogue);
164
+ patch.theme = theme.id;
165
+ }
166
+
167
+ return {
168
+ success: true,
169
+ message: describeChange(patch, theme, catalogue.getAppearance()),
170
+ data: { appearance: { ...patch } },
171
+ hostActions: [{ type: 'appearance-settings-patch', patch }],
172
+ };
173
+ }
174
+
175
+ export function createThemeCommandEntry(): ICommand {
176
+ return {
177
+ name: 'theme',
178
+ displayName: 'Theme',
179
+ description: THEME_DESCRIPTION,
180
+ argumentHint: THEME_ARGUMENT_HINT,
181
+ source: 'theme',
182
+ userInvocable: true,
183
+ // User-only: UI preference.
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'],
188
+ };
189
+ }
190
+
191
+ export class ThemeCommandSource implements ICommandSource {
192
+ readonly name = 'theme';
193
+
194
+ getCommands(): ICommand[] {
195
+ return [createThemeCommandEntry()];
196
+ }
197
+ }
198
+
199
+ export function createThemeCommandModule(
200
+ catalogue: IThemeCataloguePort | undefined,
201
+ ): ICommandModule {
202
+ const entry = createThemeCommandEntry();
203
+ const command: ISystemCommand = {
204
+ name: entry.name,
205
+ displayName: entry.displayName,
206
+ description: entry.description,
207
+ argumentHint: entry.argumentHint,
208
+ requiresPermission: false,
209
+ userInvocable: true,
210
+ modelInvocable: false,
211
+ runner: entry.runner,
212
+ surfaces: entry.surfaces,
213
+ lifecycle: 'inline',
214
+ execute: (_context, args) => executeThemeCommand(catalogue, args),
215
+ };
216
+ return {
217
+ name: 'agent-command-theme',
218
+ commandSources: [new ThemeCommandSource()],
219
+ systemCommands: [command],
220
+ };
221
+ }
@@ -0,0 +1,180 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { executeUserLocalCommand, executeUserLocalDirectCommand } from '../user-local-command.js';
6
+ import { createUserLocalCommandModule } from '../user-local-command-module.js';
7
+
8
+ const tempRoots: string[] = [];
9
+
10
+ async function createTempRoot(name: string): Promise<string> {
11
+ const root = await fs.mkdtemp(path.join(tmpdir(), name));
12
+ tempRoots.push(root);
13
+ return root;
14
+ }
15
+
16
+ afterEach(async () => {
17
+ vi.unstubAllEnvs();
18
+ await Promise.all(
19
+ tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
20
+ );
21
+ });
22
+
23
+ describe('user-local command', () => {
24
+ it('does not advertise a specific product from the neutral command entry', async () => {
25
+ const module = createUserLocalCommandModule(await createTempRoot('agent-test-selected-storage-'));
26
+ expect(module.commandSources?.[0]?.getCommands()[0]?.description).not.toMatch(/ConversationAgent/i);
27
+ });
28
+
29
+ it('fails closed when a direct caller omits the storage root', async () => {
30
+ const workspace = await createTempRoot('agent-test-user-local-missing-root-');
31
+ const repo = path.join(workspace, 'repo');
32
+ await fs.mkdir(repo);
33
+
34
+ const result = await executeUserLocalDirectCommand({
35
+ cwd: repo,
36
+ argv: ['storage', 'list'],
37
+ } as never);
38
+ expect(result).toMatchObject({ success: false });
39
+ expect(result.message).toContain('userLocalStorageRoot is required');
40
+ });
41
+
42
+ it('uses the explicit root for slash commands', async () => {
43
+ const workspace = await createTempRoot('agent-test-user-local-slash-root-');
44
+ const repo = path.join(workspace, 'repo');
45
+ const selectedRoot = path.join(workspace, 'selected', '.fixture-state');
46
+ await fs.mkdir(repo);
47
+ vi.stubEnv('HOME', path.join(workspace, 'ambient-home'));
48
+
49
+ const directResult = await executeUserLocalCommand(
50
+ { getCwd: () => repo } as never,
51
+ 'storage list --format=json',
52
+ selectedRoot,
53
+ );
54
+ expect(directResult.success).toBe(true);
55
+ expect(JSON.parse(directResult.message)).toMatchObject({ root: selectedRoot });
56
+
57
+ const slashCommand = createUserLocalCommandModule(selectedRoot).systemCommands?.[0];
58
+ expect(slashCommand).toBeDefined();
59
+ const result = await slashCommand!.execute(
60
+ { getCwd: () => repo } as never,
61
+ 'storage list --format=json',
62
+ );
63
+ expect(result.success).toBe(true);
64
+ expect(JSON.parse(result.message)).toMatchObject({ root: selectedRoot });
65
+ });
66
+
67
+ it('prints storage inspection JSON without provider configuration', async () => {
68
+ const workspace = await createTempRoot('agent-test-user-local-command-');
69
+ const repo = path.join(workspace, 'repo');
70
+ const home = path.join(workspace, 'home');
71
+ await fs.mkdir(repo);
72
+ await fs.mkdir(home);
73
+ vi.stubEnv('HOME', path.join(workspace, 'ambient-home'));
74
+
75
+ const result = await executeUserLocalDirectCommand({
76
+ cwd: repo,
77
+ storageRoot: path.join(home, '.fixture-state'),
78
+ argv: ['storage', 'list'],
79
+ format: 'json',
80
+ });
81
+ const parsed = JSON.parse(result.message) as {
82
+ root: string;
83
+ categories: readonly { category: string; mayExecuteCommands: boolean }[];
84
+ };
85
+
86
+ expect(result.success).toBe(true);
87
+ expect(parsed.root).toBe(path.join(home, '.fixture-state'));
88
+ expect(parsed.categories.map((item) => item.category)).toEqual([
89
+ 'preferences',
90
+ 'view-state',
91
+ 'memory-projections',
92
+ 'task-associations',
93
+ 'workflow-metadata',
94
+ 'inspection-index',
95
+ ]);
96
+ expect(parsed.categories.every((item) => item.mayExecuteCommands === false)).toBe(true);
97
+ });
98
+
99
+ it('stores, lists, inspects, disables, and deletes user-local memory items', async () => {
100
+ const workspace = await createTempRoot('agent-test-user-local-memory-command-');
101
+ const repo = path.join(workspace, 'repo');
102
+ const home = path.join(workspace, 'home');
103
+ await fs.mkdir(repo);
104
+ await fs.mkdir(home);
105
+ vi.stubEnv('HOME', path.join(workspace, 'ambient-home'));
106
+
107
+ const setResult = await executeUserLocalDirectCommand({
108
+ cwd: repo,
109
+ storageRoot: path.join(home, '.fixture-state'),
110
+ argv: ['memory', 'set', 'view-preference', 'last-panel', 'background'],
111
+ summary: 'Open the background panel',
112
+ source: 'user-input',
113
+ });
114
+ expect(setResult.success).toBe(true);
115
+ expect(setResult.message).toContain('view-preference/last-panel');
116
+
117
+ const listResult = await executeUserLocalDirectCommand({
118
+ cwd: repo,
119
+ storageRoot: path.join(home, '.fixture-state'),
120
+ argv: ['memory', 'list'],
121
+ format: 'json',
122
+ });
123
+ const list = JSON.parse(listResult.message) as {
124
+ items: readonly { category: string; key: string; enabled: boolean }[];
125
+ };
126
+ expect(listResult.success).toBe(true);
127
+ expect(list.items).toHaveLength(1);
128
+ expect(list.items[0]).toMatchObject({
129
+ category: 'view-preference',
130
+ key: 'last-panel',
131
+ enabled: true,
132
+ });
133
+
134
+ const inspectResult = await executeUserLocalDirectCommand({
135
+ cwd: repo,
136
+ storageRoot: path.join(home, '.fixture-state'),
137
+ argv: ['memory', 'inspect', 'view-preference', 'last-panel'],
138
+ format: 'json',
139
+ });
140
+ const inspected = JSON.parse(inspectResult.message) as {
141
+ commandExecutionEffect: string;
142
+ displayNavigationRule: string;
143
+ };
144
+ expect(inspectResult.success).toBe(true);
145
+ expect(inspected.commandExecutionEffect).toBe('none');
146
+ expect(inspected.displayNavigationRule).toContain('display/navigation only');
147
+
148
+ const disableResult = await executeUserLocalDirectCommand({
149
+ cwd: repo,
150
+ storageRoot: path.join(home, '.fixture-state'),
151
+ argv: ['memory', 'disable', 'view-preference', 'last-panel'],
152
+ });
153
+ expect(disableResult.success).toBe(true);
154
+
155
+ const disabledInspectResult = await executeUserLocalDirectCommand({
156
+ cwd: repo,
157
+ storageRoot: path.join(home, '.fixture-state'),
158
+ argv: ['memory', 'inspect', 'view-preference', 'last-panel'],
159
+ format: 'json',
160
+ });
161
+ const disabled = JSON.parse(disabledInspectResult.message) as { enabled: boolean };
162
+ expect(disabled.enabled).toBe(false);
163
+
164
+ const deleteResult = await executeUserLocalDirectCommand({
165
+ cwd: repo,
166
+ storageRoot: path.join(home, '.fixture-state'),
167
+ argv: ['memory', 'delete', 'view-preference', 'last-panel'],
168
+ });
169
+ expect(deleteResult.success).toBe(true);
170
+
171
+ const missingResult = await executeUserLocalDirectCommand({
172
+ cwd: repo,
173
+ storageRoot: path.join(home, '.fixture-state'),
174
+ argv: ['memory', 'inspect', 'view-preference', 'last-panel'],
175
+ format: 'json',
176
+ });
177
+ expect(missingResult.success).toBe(false);
178
+ expect(missingResult.message).toBe('User-local memory item not found.');
179
+ });
180
+ });
@@ -0,0 +1,13 @@
1
+ export {
2
+ UserLocalCommandSource,
3
+ createUserLocalCommandEntry,
4
+ createUserLocalCommandModule,
5
+ } from './user-local-command-module.js';
6
+ export {
7
+ USER_LOCAL_COMMAND_ARGUMENT_HINT,
8
+ USER_LOCAL_COMMAND_DESCRIPTION,
9
+ USER_LOCAL_COMMAND_USAGE,
10
+ executeUserLocalCommand,
11
+ executeUserLocalDirectCommand,
12
+ } from './user-local-command.js';
13
+ export type { IUserLocalDirectCommandOptions } from './user-local-command.js';
@@ -0,0 +1,5 @@
1
+ export const USER_LOCAL_COMMAND_DESCRIPTION = 'Inspect user-local storage and memory state.';
2
+ export const USER_LOCAL_COMMAND_ARGUMENT_HINT =
3
+ 'storage list [--format json] | memory set/list/inspect/disable/delete';
4
+ export const USER_LOCAL_COMMAND_USAGE =
5
+ 'Usage: user-local storage list [--format json] | user-local memory set <category> <key> <value> --summary <summary> --source <source> | user-local memory list [--format json] | user-local memory inspect <category> <key> [--format json] | user-local memory disable <category> <key> | user-local memory delete <category> <key>';