@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,11 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import * as commands from '../../index.js';
4
+
5
+ describe('plugin command host boundary', () => {
6
+ it('keeps the concrete plugin host adapter out of the command package entrypoint', () => {
7
+ expect(commands).not.toHaveProperty('createDefaultPluginCommandAdapter');
8
+ expect(commands).not.toHaveProperty('reloadPluginCommandSource');
9
+ expect(commands).not.toHaveProperty('pluginScopeDirs');
10
+ });
11
+ });
@@ -0,0 +1,7 @@
1
+ export {
2
+ createPluginCommandEntry,
3
+ createPluginCommandModule,
4
+ createReloadPluginsCommandEntry,
5
+ PluginManagerCommandSource,
6
+ } from './plugin-command-module.js';
7
+ export { executePluginCommand, executeReloadPluginsCommand } from './plugin-command.js';
@@ -0,0 +1,85 @@
1
+ import {
2
+ buildPluginCommandSubcommands,
3
+ PLUGIN_COMMAND_ARGUMENT_HINT,
4
+ PLUGIN_COMMAND_DESCRIPTION,
5
+ RELOAD_PLUGINS_COMMAND_DESCRIPTION,
6
+ } from '@robota-sdk/agent-framework';
7
+
8
+ import { executePluginCommand, executeReloadPluginsCommand } from './plugin-command.js';
9
+
10
+ import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
11
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
12
+
13
+ export function createPluginCommandEntry(): ICommand {
14
+ return {
15
+ name: 'plugin',
16
+ displayName: 'Plugins',
17
+ description: PLUGIN_COMMAND_DESCRIPTION,
18
+ source: 'plugin-manager',
19
+ // User-only: installs and enables code; a trust decision.
20
+ modelInvocable: false,
21
+ argumentHint: PLUGIN_COMMAND_ARGUMENT_HINT,
22
+ subcommands: buildPluginCommandSubcommands(),
23
+ };
24
+ }
25
+
26
+ export function createReloadPluginsCommandEntry(): ICommand {
27
+ return {
28
+ name: 'reload-plugins',
29
+ displayName: 'Reload Plugins',
30
+ description: RELOAD_PLUGINS_COMMAND_DESCRIPTION,
31
+ source: 'plugin-manager',
32
+ // User-only: reloading activates whatever plugin code is installed now; a trust decision.
33
+ modelInvocable: false,
34
+ };
35
+ }
36
+
37
+ function createPluginSystemCommand(): ISystemCommand {
38
+ const entry = createPluginCommandEntry();
39
+ return {
40
+ name: entry.name,
41
+ displayName: entry.displayName,
42
+ description: entry.description,
43
+ requiresPermission: false,
44
+ userInvocable: true,
45
+ modelInvocable: false,
46
+ argumentHint: entry.argumentHint,
47
+ lifecycle: 'inline',
48
+ canRunDuringTurn: (args) => {
49
+ const parts = args.trim().split(/\s+/);
50
+ return parts.length === 2 && ['disable', 'uninstall'].includes(parts[0]!);
51
+ },
52
+ subcommands: entry.subcommands,
53
+ execute: executePluginCommand,
54
+ };
55
+ }
56
+
57
+ function createReloadPluginsSystemCommand(): ISystemCommand {
58
+ const entry = createReloadPluginsCommandEntry();
59
+ return {
60
+ name: entry.name,
61
+ displayName: entry.displayName,
62
+ description: entry.description,
63
+ requiresPermission: false,
64
+ userInvocable: true,
65
+ modelInvocable: false,
66
+ lifecycle: 'inline',
67
+ execute: executeReloadPluginsCommand,
68
+ };
69
+ }
70
+
71
+ export class PluginManagerCommandSource implements ICommandSource {
72
+ readonly name = 'plugin-manager';
73
+
74
+ getCommands(): ICommand[] {
75
+ return [createPluginCommandEntry(), createReloadPluginsCommandEntry()];
76
+ }
77
+ }
78
+
79
+ export function createPluginCommandModule(): ICommandModule {
80
+ return {
81
+ name: 'agent-command-plugin',
82
+ commandSources: [new PluginManagerCommandSource()],
83
+ systemCommands: [createPluginSystemCommand(), createReloadPluginsSystemCommand()],
84
+ };
85
+ }
@@ -0,0 +1,271 @@
1
+ import {
2
+ createShowPluginManagerIntent,
3
+ resolvePluginCommandAdapter,
4
+ } from '@robota-sdk/agent-framework';
5
+
6
+ import type { ICommandHostAdapterAccess, ICommandHostWorkspace } from '@robota-sdk/agent-framework';
7
+ import type { ICommandPluginAdapter, ICommandResult } from '@robota-sdk/agent-interface-command';
8
+
9
+ /** What `/plugin` reaches: its adapter port, and — for install/uninstall — this connection's locality. */
10
+ export type TPluginCommandContext = ICommandHostAdapterAccess &
11
+ Partial<Pick<ICommandHostWorkspace, 'getCommandSurfaceLocality'>>;
12
+
13
+ function getSubcommandParts(args: string): { subcommand: string; subArgs: string } {
14
+ const parts = args
15
+ .trim()
16
+ .split(/\s+/)
17
+ .filter((part) => part.length > 0);
18
+ return {
19
+ subcommand: parts[0] ?? '',
20
+ subArgs: parts.slice(1).join(' ').trim(),
21
+ };
22
+ }
23
+
24
+ function usage(message: string): ICommandResult {
25
+ return {
26
+ success: false,
27
+ message,
28
+ };
29
+ }
30
+
31
+ function getPluginAdapter(context: ICommandHostAdapterAccess): ICommandPluginAdapter | undefined {
32
+ return resolvePluginCommandAdapter(context);
33
+ }
34
+
35
+ const REMOTE_INSTALL_REFUSAL =
36
+ 'Installing and uninstalling plugins runs code on this computer, so it only works from the ' +
37
+ 'desktop app or the page opened here — not from a remote device.';
38
+
39
+ /**
40
+ * #3282 §4 part b-2: the same rule the GUI's Settings screen applies before it ever sends the
41
+ * write — installing/uninstalling stays local-surface-only because it runs third-party code.
42
+ * `getCommandSurfaceLocality` absent (an older host, a test double, the in-process TUI) reads as
43
+ * `'local'`, matching the allow-by-default posture the rest of the command layer already uses.
44
+ */
45
+ function isRemoteSurface(context: TPluginCommandContext): boolean {
46
+ return (context.getCommandSurfaceLocality?.() ?? 'local') === 'remote';
47
+ }
48
+
49
+ async function executePluginOperation(
50
+ context: ICommandHostAdapterAccess,
51
+ operation: (adapter: ICommandPluginAdapter) => Promise<string>,
52
+ ): Promise<ICommandResult> {
53
+ const adapter = getPluginAdapter(context);
54
+ if (adapter === undefined) {
55
+ return {
56
+ success: false,
57
+ message: 'Plugin management is not available.',
58
+ };
59
+ }
60
+
61
+ try {
62
+ return {
63
+ success: true,
64
+ message: await operation(adapter),
65
+ };
66
+ } catch (error) {
67
+ const message = error instanceof Error ? error.message : String(error);
68
+ return {
69
+ success: false,
70
+ message: `Plugin error: ${message}`,
71
+ };
72
+ }
73
+ }
74
+
75
+ async function executeMarketplaceCommand(
76
+ context: ICommandHostAdapterAccess,
77
+ subArgs: string,
78
+ ): Promise<ICommandResult> {
79
+ const { subcommand, subArgs: marketplaceArgs } = getSubcommandParts(subArgs);
80
+
81
+ if (subcommand === 'add' && marketplaceArgs.length > 0) {
82
+ return executePluginOperation(context, async (adapter) => {
83
+ const registeredName = await adapter.marketplaceAdd(marketplaceArgs);
84
+ return `Added marketplace: "${registeredName}" (from ${marketplaceArgs})\nInstall plugins with: /plugin install <name>@${registeredName}`;
85
+ });
86
+ }
87
+
88
+ if (subcommand === 'remove' && marketplaceArgs.length > 0) {
89
+ return executePluginOperation(context, async (adapter) => {
90
+ await adapter.marketplaceRemove(marketplaceArgs);
91
+ return `Removed marketplace "${marketplaceArgs}" and uninstalled its plugins.`;
92
+ });
93
+ }
94
+
95
+ if (subcommand === 'update' && marketplaceArgs.length > 0) {
96
+ return executePluginOperation(context, async (adapter) => {
97
+ await adapter.marketplaceUpdate(marketplaceArgs);
98
+ return `Updated marketplace "${marketplaceArgs}".`;
99
+ });
100
+ }
101
+
102
+ if (subcommand === 'list') {
103
+ return executePluginOperation(context, async (adapter) => {
104
+ const sources = await adapter.marketplaceList();
105
+ if (sources.length === 0) {
106
+ return 'No marketplace sources configured.';
107
+ }
108
+ const lines = sources.map((source) => ` ${source.name} (${source.type})`);
109
+ return `Marketplace sources:\n${lines.join('\n')}`;
110
+ });
111
+ }
112
+
113
+ return usage('Usage: /plugin marketplace add <source> | remove <name> | update <name> | list');
114
+ }
115
+
116
+ type TPluginIdOperation = (
117
+ adapter: ICommandPluginAdapter,
118
+ pluginId: string,
119
+ ) => Promise<string> | string;
120
+
121
+ function executePluginIdOperation(
122
+ context: ICommandHostAdapterAccess,
123
+ pluginId: string,
124
+ usageMessage: string,
125
+ operation: TPluginIdOperation,
126
+ ): Promise<ICommandResult> {
127
+ if (pluginId.length === 0) {
128
+ return Promise.resolve(usage(usageMessage));
129
+ }
130
+ return executePluginOperation(context, (adapter) =>
131
+ Promise.resolve(operation(adapter, pluginId)),
132
+ );
133
+ }
134
+
135
+ function executePluginManager(): ICommandResult {
136
+ return {
137
+ success: true,
138
+ message: 'Opening plugin manager...',
139
+ uiIntents: [createShowPluginManagerIntent()],
140
+ };
141
+ }
142
+
143
+ function executeInstallCommand(
144
+ context: TPluginCommandContext,
145
+ pluginId: string,
146
+ ): Promise<ICommandResult> {
147
+ if (isRemoteSurface(context)) return Promise.resolve(usage(REMOTE_INSTALL_REFUSAL));
148
+ return executePluginIdOperation(
149
+ context,
150
+ pluginId,
151
+ 'Usage: /plugin install <name>@<marketplace>',
152
+ async (adapter, targetPluginId) => {
153
+ await adapter.install(targetPluginId);
154
+ return `Installed plugin: ${targetPluginId}`;
155
+ },
156
+ );
157
+ }
158
+
159
+ function executeUninstallCommand(
160
+ context: TPluginCommandContext,
161
+ pluginId: string,
162
+ ): Promise<ICommandResult> {
163
+ if (isRemoteSurface(context)) return Promise.resolve(usage(REMOTE_INSTALL_REFUSAL));
164
+ return executePluginIdOperation(
165
+ context,
166
+ pluginId,
167
+ 'Usage: /plugin uninstall <name>@<marketplace>',
168
+ async (adapter, targetPluginId) => {
169
+ await adapter.uninstall(targetPluginId);
170
+ return `Uninstalled plugin: ${targetPluginId}`;
171
+ },
172
+ );
173
+ }
174
+
175
+ /** #3282 §4 part b-2: the Settings screen's Plugins section reads through this, like `/mcp status`. */
176
+ async function executeListInstalledCommand(
177
+ context: ICommandHostAdapterAccess,
178
+ ): Promise<ICommandResult> {
179
+ const adapter = getPluginAdapter(context);
180
+ if (adapter === undefined) {
181
+ return { success: true, message: 'Plugin management is not available.', data: { plugins: [] } };
182
+ }
183
+ try {
184
+ const plugins = await adapter.listInstalled();
185
+ const message =
186
+ plugins.length === 0
187
+ ? 'No plugins are installed.'
188
+ : `Installed plugins:\n${plugins.map((plugin) => ` ${plugin.name}${plugin.enabled ? '' : ' (disabled)'} — ${plugin.description}`).join('\n')}`;
189
+ return { success: true, message, data: { plugins } };
190
+ } catch (error) {
191
+ const message = error instanceof Error ? error.message : String(error);
192
+ return { success: false, message: `Plugin error: ${message}` };
193
+ }
194
+ }
195
+
196
+ function executeEnableCommand(
197
+ context: ICommandHostAdapterAccess,
198
+ pluginId: string,
199
+ ): Promise<ICommandResult> {
200
+ return executePluginIdOperation(
201
+ context,
202
+ pluginId,
203
+ 'Usage: /plugin enable <name>@<marketplace>',
204
+ async (adapter, targetPluginId) => {
205
+ await adapter.enable(targetPluginId);
206
+ return `Enabled plugin: ${targetPluginId}`;
207
+ },
208
+ );
209
+ }
210
+
211
+ function executeDisableCommand(
212
+ context: ICommandHostAdapterAccess,
213
+ pluginId: string,
214
+ ): Promise<ICommandResult> {
215
+ return executePluginIdOperation(
216
+ context,
217
+ pluginId,
218
+ 'Usage: /plugin disable <name>@<marketplace>',
219
+ async (adapter, targetPluginId) => {
220
+ await adapter.disable(targetPluginId);
221
+ return `Disabled plugin: ${targetPluginId}`;
222
+ },
223
+ );
224
+ }
225
+
226
+ export async function executePluginCommand(
227
+ context: TPluginCommandContext,
228
+ args: string,
229
+ ): Promise<ICommandResult> {
230
+ const { subcommand, subArgs } = getSubcommandParts(args);
231
+ switch (subcommand) {
232
+ case '':
233
+ case 'manage':
234
+ return executePluginManager();
235
+ case 'list':
236
+ return executeListInstalledCommand(context);
237
+ case 'install':
238
+ return executeInstallCommand(context, subArgs);
239
+ case 'uninstall':
240
+ return executeUninstallCommand(context, subArgs);
241
+ case 'enable':
242
+ return executeEnableCommand(context, subArgs);
243
+ case 'disable':
244
+ return executeDisableCommand(context, subArgs);
245
+ case 'marketplace':
246
+ return executeMarketplaceCommand(context, subArgs);
247
+ default:
248
+ return usage(`Unknown plugin subcommand: ${subcommand}`);
249
+ }
250
+ }
251
+
252
+ export async function executeReloadPluginsCommand(
253
+ context: ICommandHostAdapterAccess,
254
+ _args: string,
255
+ ): Promise<ICommandResult> {
256
+ return executePluginOperation(context, async (adapter) => {
257
+ const result = await adapter.reloadPlugins();
258
+ const suffix =
259
+ result.loadedPluginCount === 1
260
+ ? '1 plugin resource'
261
+ : `${result.loadedPluginCount} plugin resources`;
262
+ return `Reloaded ${suffix}. Plugin commands and skills are active now; hook changes apply to new sessions.`;
263
+ }).then((result) => {
264
+ // A failed reload must also refresh the admitted snapshot: malformed settings must not leave
265
+ // previously loaded code executable. Preserve the error while requesting the same refresh.
266
+ return {
267
+ ...result,
268
+ data: { ...result.data, pluginRegistryReloaded: true },
269
+ };
270
+ });
271
+ }
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { executePresetCommand } from '../preset-command.js';
4
+
5
+ /**
6
+ * ARCH-009 — `/preset` discovers through the registry the HOST resolved with.
7
+ *
8
+ * ARCH-008 moved startup preset resolution onto a per-call instance registry, but `/preset` kept
9
+ * reading `agent-preset`'s module-global one, because a command runs with only an
10
+ * `ICommandHostContext` and had no path to the instance the shell built. That left the last mutable
11
+ * process-global on this axis load-bearing, and it made ARCH-008's R8 property — two products in one
12
+ * process, each with its own presets — only half-true: both would have shared one registry.
13
+ *
14
+ * The registry arrives in the host-ADAPTER bag, the path `/permission-mode` and `/plugin` already take
15
+ * to their host capabilities. A capability the composition root supplies is what that bag is for, and
16
+ * reusing it means this seam has no shape of its own to get wrong.
17
+ *
18
+ * The decisive case is the last one. Everything else is a property of a single host; only "two hosts,
19
+ * one process" is impossible to satisfy with a global, which is why it is the one that proves the
20
+ * seam rather than merely exercising it.
21
+ */
22
+ function presetSummary(id: string) {
23
+ return { id, title: `${id} title`, description: `${id} description` };
24
+ }
25
+
26
+ /** A host whose registry contains exactly the ids it was built with. */
27
+ function hostWithPresets(ids: string[]) {
28
+ const applied: string[] = [];
29
+ return {
30
+ applied,
31
+ context: {
32
+ getCommandHostAdapters: () => ({
33
+ presetRegistry: {
34
+ listPresets: () => ids.map(presetSummary),
35
+ getPreset: (id: string) => (ids.includes(id) ? presetSummary(id) : undefined),
36
+ resolvePreset: (id: string) => ({ id }),
37
+ },
38
+ }),
39
+ getSession: () => ({ getActivePresetId: () => 'default' }),
40
+ getUserInteraction: () => undefined,
41
+ applyPersona: vi.fn(),
42
+ applySelfVerification: vi.fn(),
43
+ applyCommandModuleSelection: () => [],
44
+ } as never,
45
+ };
46
+ }
47
+
48
+ describe('ARCH-009 — /preset reads the host-supplied registry', () => {
49
+ it('lists the host registry, not the built-ins', async () => {
50
+ const { context } = hostWithPresets(['acme-reviewer']);
51
+
52
+ const result = await executePresetCommand(context, 'list');
53
+
54
+ expect(result.message).toContain('acme-reviewer');
55
+ // `careful-reviewer` is a shipped built-in and is what the no-registry fallback would list. Its
56
+ // absence here is what shows the fallback was not taken.
57
+ expect(result.message).not.toContain('careful-reviewer');
58
+ });
59
+
60
+ it('rejects an id the HOST does not have, even though a built-in would resolve it', async () => {
61
+ const { context } = hostWithPresets(['acme-reviewer']);
62
+
63
+ const result = await executePresetCommand(context, 'careful-reviewer');
64
+
65
+ expect(result.success).toBe(false);
66
+ expect(result.message).toContain('Unknown preset');
67
+ });
68
+
69
+ it('falls back to the built-ins when the host supplies no registry', async () => {
70
+ // Deliberate, not transitional: a host that never loads external presets has nothing to hand
71
+ // over, and `/preset` should still list the built-ins rather than reporting an empty catalog.
72
+ const context = {
73
+ getSession: () => ({ getActivePresetId: () => 'default' }),
74
+ getUserInteraction: () => undefined,
75
+ applyPersona: vi.fn(),
76
+ applySelfVerification: vi.fn(),
77
+ applyCommandModuleSelection: () => [],
78
+ } as never;
79
+
80
+ const result = await executePresetCommand(context, 'list');
81
+
82
+ expect(result.success).toBe(true);
83
+ expect(result.message).toContain('Available presets:');
84
+ });
85
+
86
+ it('gives two hosts in ONE process their own presets — the property a global makes impossible', async () => {
87
+ const first = hostWithPresets(['acme-reviewer']);
88
+ const second = hostWithPresets(['globex-auditor']);
89
+
90
+ const firstList = await executePresetCommand(first.context, 'list');
91
+ const secondList = await executePresetCommand(second.context, 'list');
92
+
93
+ expect(firstList.message).toContain('acme-reviewer');
94
+ expect(firstList.message).not.toContain('globex-auditor');
95
+ expect(secondList.message).toContain('globex-auditor');
96
+ expect(secondList.message).not.toContain('acme-reviewer');
97
+ });
98
+ });
@@ -0,0 +1,220 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createPresetRegistry } from '@robota-sdk/agent-preset';
3
+ import type {
4
+ ICommandHostContext,
5
+ ICommandSessionRuntime,
6
+ IUnknownCommandModuleName,
7
+ } from '@robota-sdk/agent-framework';
8
+ import { createPresetCommandModule, executePresetCommand } from '../index.js';
9
+ import { createTestSessionRuntime } from '@robota-sdk/agent-framework/testing';
10
+ import {
11
+ createTestCommandHost,
12
+ type ICreateTestCommandHostOptions,
13
+ } from '@robota-sdk/agent-framework/testing';
14
+
15
+ function createSessionRuntime(overrides?: Partial<ICommandSessionRuntime>) {
16
+ return createTestSessionRuntime({
17
+ clearHistory: () => undefined,
18
+ compact: async () => undefined,
19
+ getContextState: () => ({
20
+ maxTokens: 100,
21
+ usedTokens: 10,
22
+ usedPercentage: 10,
23
+ remainingPercentage: 90,
24
+ }),
25
+ getPermissionMode: () => 'default',
26
+ setPermissionMode: () => undefined,
27
+ getSessionId: () => 'session_1',
28
+ getMessageCount: () => 0,
29
+ getSessionAllowedTools: () => [],
30
+ getAutoCompactThreshold: () => false,
31
+ getFullHistory: () => [],
32
+ getHistory: () => [],
33
+ ...overrides,
34
+ });
35
+ }
36
+
37
+ function createCommandHostContext(
38
+ runtime: ICommandSessionRuntime,
39
+ overrides?: ICreateTestCommandHostOptions['overrides'],
40
+ ) {
41
+ return createTestCommandHost({
42
+ overrides: {
43
+ getSession: () => runtime,
44
+ getContextState: () => ({
45
+ maxTokens: 100,
46
+ usedTokens: 10,
47
+ usedPercentage: 10,
48
+ remainingPercentage: 90,
49
+ }),
50
+ getAutoCompactThreshold: () => 0.8,
51
+ compactContext: async () => undefined,
52
+ getCwd: () => '/workspace',
53
+ listEditCheckpoints: () => [],
54
+ restoreEditCheckpoint: async () => ({
55
+ target: {
56
+ id: 'checkpoint_1',
57
+ sessionId: 'session_1',
58
+ sequence: 1,
59
+ prompt: 'edit',
60
+ createdAt: '2026-05-03T00:00:00.000Z',
61
+ fileCount: 0,
62
+ },
63
+ restoredCheckpointCount: 1,
64
+ restoredFileCount: 0,
65
+ removedCheckpointCount: 0,
66
+ }),
67
+ rollbackEditCheckpoint: async () => ({
68
+ target: {
69
+ id: 'checkpoint_1',
70
+ sessionId: 'session_1',
71
+ sequence: 1,
72
+ prompt: 'edit',
73
+ createdAt: '2026-05-03T00:00:00.000Z',
74
+ fileCount: 0,
75
+ },
76
+ restoredCheckpointCount: 1,
77
+ restoredFileCount: 0,
78
+ removedCheckpointCount: 0,
79
+ }),
80
+ getUsedMemoryReferences: () => [],
81
+ recordMemoryEvent: () => undefined,
82
+ listBackgroundTasks: () => [],
83
+ readBackgroundTaskLog: async (taskId) => ({ taskId, lines: [] }),
84
+ cancelBackgroundTask: async () => undefined,
85
+ closeBackgroundTask: async () => undefined,
86
+ ...overrides,
87
+ },
88
+ });
89
+ }
90
+
91
+ describe('preset command module', () => {
92
+ it('exposes a single preset system command (structure)', async () => {
93
+ const module = createPresetCommandModule();
94
+ expect(module.systemCommands?.map((command) => command.name)).toContain('preset');
95
+ });
96
+
97
+ it('TC-01: lists every preset and marks the active one', async () => {
98
+ const runtime = createSessionRuntime({ getActivePresetId: () => 'autonomous-builder' });
99
+ const context = createCommandHostContext(runtime);
100
+
101
+ const result = await executePresetCommand(context, '');
102
+
103
+ expect(result.success).toBe(true);
104
+ for (const preset of createPresetRegistry().listPresets()) {
105
+ expect(result.message).toContain(preset.id);
106
+ }
107
+ // The active preset is marked with the `* ` prefix.
108
+ expect(result.message).toContain('* autonomous-builder');
109
+ expect(result.message).not.toContain('* default');
110
+ expect(result.data?.active).toBe('autonomous-builder');
111
+ });
112
+
113
+ it('TC-02: switches to a valid preset and drives the live re-apply seams', async () => {
114
+ const setActivePresetId = vi.fn();
115
+ const setPermissionMode = vi.fn();
116
+ const applyModelOptions = vi.fn();
117
+ const runtime = createSessionRuntime({
118
+ setActivePresetId,
119
+ setPermissionMode,
120
+ applyModelOptions,
121
+ });
122
+ const context = createCommandHostContext(runtime);
123
+
124
+ const result = await executePresetCommand(context, 'careful-reviewer');
125
+
126
+ expect(result.success).toBe(true);
127
+ expect(result.message).toBe('Switched to preset: careful-reviewer');
128
+ expect(result.data?.preset).toBe('careful-reviewer');
129
+ expect(setActivePresetId).toHaveBeenCalledWith('careful-reviewer');
130
+ // careful-reviewer resolves autonomy ask-first → default permission posture (PRESET-012)
131
+ // and effort high → applyModelOptions (PRESET-013).
132
+ expect(setPermissionMode).toHaveBeenCalledWith('default');
133
+ expect(applyModelOptions).toHaveBeenCalledWith(expect.objectContaining({ effort: 'high' }));
134
+ });
135
+
136
+ it('TC-04: rejects an unknown preset id without switching', async () => {
137
+ const setActivePresetId = vi.fn();
138
+ const runtime = createSessionRuntime({ setActivePresetId });
139
+ const context = createCommandHostContext(runtime);
140
+
141
+ const result = await executePresetCommand(context, '__nope__');
142
+
143
+ expect(result.success).toBe(false);
144
+ for (const preset of createPresetRegistry().listPresets()) {
145
+ expect(result.message).toContain(preset.id);
146
+ }
147
+ expect(setActivePresetId).not.toHaveBeenCalled();
148
+ });
149
+
150
+ it('CMD-004: asks the user to pick a preset when none is given, then switches', async () => {
151
+ const setActivePresetId = vi.fn();
152
+ const setPermissionMode = vi.fn();
153
+ const applyModelOptions = vi.fn();
154
+ const runtime = createSessionRuntime({
155
+ setActivePresetId,
156
+ setPermissionMode,
157
+ applyModelOptions,
158
+ });
159
+ const context = createCommandHostContext(runtime, {
160
+ getUserInteraction: () => ({
161
+ ask: async () => ({ type: 'answer', values: ['careful-reviewer'] }),
162
+ }),
163
+ });
164
+
165
+ const result = await executePresetCommand(context, '');
166
+
167
+ expect(result.success).toBe(true);
168
+ expect(result.message).toBe('Switched to preset: careful-reviewer');
169
+ expect(setActivePresetId).toHaveBeenCalledWith('careful-reviewer');
170
+ });
171
+
172
+ it('CMD-004: shows the preset list when the pick is cancelled', async () => {
173
+ const setActivePresetId = vi.fn();
174
+ const runtime = createSessionRuntime({ setActivePresetId });
175
+ const context = createCommandHostContext(runtime, {
176
+ getUserInteraction: () => ({ ask: async () => ({ type: 'cancelled' }) }),
177
+ });
178
+
179
+ const result = await executePresetCommand(context, '');
180
+
181
+ expect(result.success).toBe(true);
182
+ expect(result.message).toContain('Available presets:');
183
+ expect(setActivePresetId).not.toHaveBeenCalled();
184
+ });
185
+
186
+ describe('INFRA-032: unmatched command-module names surface in the /preset result', () => {
187
+ it('a preset disabling an unknown module name reports it (no longer silent)', async () => {
188
+ // ARCH-009: the external preset reaches `/preset` through the HOST, not through a process-wide
189
+ // registration this case used to perform and then have to undo in an afterEach. This is the
190
+ // seam the item added, exercised by a case that already needed an external preset.
191
+ const registry = createPresetRegistry([
192
+ {
193
+ id: 'ext-unknown-module',
194
+ title: 'Ext Unknown Module',
195
+ description: 'disables a short-form command module name',
196
+ disabledCommandModules: ['editor'],
197
+ },
198
+ ]);
199
+ const unknowns: readonly IUnknownCommandModuleName[] = [{ name: 'editor', kind: 'disabled' }];
200
+ const applyCommandModuleSelection = vi.fn(() => unknowns);
201
+ const runtime = createSessionRuntime({ setActivePresetId: vi.fn() });
202
+ const context = createCommandHostContext(runtime, {
203
+ applyCommandModuleSelection,
204
+ getCommandHostAdapters: () => ({ presetRegistry: registry }),
205
+ });
206
+
207
+ const result = await executePresetCommand(context, 'ext-unknown-module');
208
+
209
+ expect(applyCommandModuleSelection).toHaveBeenCalledWith(undefined, ['editor']);
210
+ // Non-fatal: the switch still succeeds…
211
+ expect(result.success).toBe(true);
212
+ expect(result.message).toContain('Switched to preset: ext-unknown-module');
213
+ // …and the unmatched name is surfaced in the command result (message + data).
214
+ expect(result.message).toContain(
215
+ 'Preset command-module "editor" (disabled) matched no module',
216
+ );
217
+ expect(result.data?.unknownCommandModules).toEqual(unknowns);
218
+ });
219
+ });
220
+ });