@robota-sdk/agent-command 3.0.0-beta.76 → 3.0.0-beta.77

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 (53) hide show
  1. package/LICENSE +661 -21
  2. package/README.md +12 -6
  3. package/dist/node/index.cjs +38 -33
  4. package/dist/node/index.d.ts +68 -6
  5. package/dist/node/index.d.ts.map +1 -1
  6. package/dist/node/index.js +40 -35
  7. package/dist/node/index.js.map +1 -1
  8. package/package.json +7 -7
  9. package/src/agent/agent-command-parser.ts +1 -1
  10. package/src/agent/agent-command.ts +2 -1
  11. package/src/background/__tests__/background-command-module.test.ts +2 -5
  12. package/src/default/__tests__/default-command-modules.test.ts +5 -2
  13. package/src/default/default-command-modules.ts +6 -0
  14. package/src/editor/__tests__/editor-command-functional.test.ts +91 -0
  15. package/src/editor/editor-command-module.ts +47 -0
  16. package/src/editor/editor-command.ts +53 -0
  17. package/src/editor/index.ts +7 -0
  18. package/src/editor/resolve-editor.ts +21 -0
  19. package/src/exit/__tests__/exit-command-module.test.ts +21 -2
  20. package/src/exit/exit-command-module.ts +1 -10
  21. package/src/exit/exit-command.ts +15 -1
  22. package/src/goal/__tests__/goal-command.test.ts +75 -0
  23. package/src/goal/goal-command-module.ts +48 -0
  24. package/src/goal/goal-command.ts +70 -0
  25. package/src/goal/index.ts +6 -0
  26. package/src/index.ts +3 -0
  27. package/src/language/__tests__/language-command-module.test.ts +16 -0
  28. package/src/language/language-command-module.ts +1 -18
  29. package/src/language/language-command.ts +35 -9
  30. package/src/mode/__tests__/mode-command-module.test.ts +34 -0
  31. package/src/mode/mode-command-module.ts +1 -18
  32. package/src/mode/mode-command.ts +31 -8
  33. package/src/preset/__tests__/preset-command-module.test.ts +36 -0
  34. package/src/preset/preset-command-module.ts +1 -18
  35. package/src/preset/preset-command.ts +37 -8
  36. package/src/provider/__tests__/org-policy.test.ts +19 -13
  37. package/src/provider/__tests__/provider-command-module.test.ts +151 -80
  38. package/src/provider/__tests__/scripted-interaction.ts +28 -0
  39. package/src/provider/provider-command-execution.ts +67 -50
  40. package/src/provider/provider-command-module.ts +3 -19
  41. package/src/provider/provider-command-profile-lifecycle.ts +52 -72
  42. package/src/provider/provider-command-profile-operations.ts +15 -51
  43. package/src/provider/provider-command-profile.ts +44 -49
  44. package/src/provider/provider-command-setup.ts +56 -51
  45. package/src/session/__tests__/session-command-module.test.ts +38 -0
  46. package/src/session/session-command-module.ts +1 -10
  47. package/src/session/session-command.ts +14 -1
  48. package/src/shell/__tests__/shell-command-functional.test.ts +96 -0
  49. package/src/shell/index.ts +8 -0
  50. package/src/shell/resolve-shell.ts +25 -0
  51. package/src/shell/shell-command-module.ts +47 -0
  52. package/src/shell/shell-command.ts +44 -0
  53. package/src/shell/spawn-inherited.ts +32 -0
@@ -102,4 +102,20 @@ describe('createLanguageCommandModule', () => {
102
102
  expect(result?.success).toBe(false);
103
103
  expect(result?.message).toBe('Usage: language <code> (e.g., ko, en, ja, zh)');
104
104
  });
105
+
106
+ it('asks the user to pick a language when none is provided (CMD-004)', async () => {
107
+ const executor = new SystemCommandExecutor([
108
+ ...(createLanguageCommandModule().systemCommands ?? []),
109
+ ]);
110
+ const contextWithAsk: ICommandHostContext = {
111
+ ...commandHostContext,
112
+ getUserInteraction: () => ({ ask: async () => ({ type: 'answer', values: ['ko'] }) }),
113
+ };
114
+
115
+ const result = await executor.execute('language', contextWithAsk, '');
116
+
117
+ expect(result?.success).toBe(true);
118
+ expect(result?.data?.language).toBe('ko');
119
+ expect(result?.effects).toEqual([{ type: 'language-change-requested', language: 'ko' }]);
120
+ });
105
121
  });
@@ -7,11 +7,7 @@ import {
7
7
  import { executeLanguageCommand } from './language-command.js';
8
8
 
9
9
  import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
10
- import type {
11
- ICommand,
12
- ICommandInteractionHint,
13
- ICommandSource,
14
- } from '@robota-sdk/agent-interface-transport';
10
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-transport';
15
11
 
16
12
  export function createLanguageCommandEntry(): ICommand {
17
13
  return {
@@ -49,23 +45,10 @@ export class LanguageCommandSource implements ICommandSource {
49
45
  }
50
46
  }
51
47
 
52
- const LANGUAGE_INTERACTION_HINTS: Record<string, ICommandInteractionHint> = {
53
- language: {
54
- type: 'pick',
55
- getItems: () =>
56
- buildLanguageCommandSubcommands().map((sub) => ({
57
- label: `${sub.name} ${sub.description ?? ''}`.trimEnd(),
58
- value: sub.name,
59
- description: sub.description,
60
- })),
61
- },
62
- };
63
-
64
48
  export function createLanguageCommandModule(): ICommandModule {
65
49
  return {
66
50
  name: 'agent-command-language',
67
51
  commandSources: [new LanguageCommandSource()],
68
52
  systemCommands: [createLanguageSystemCommand()],
69
- interactionHints: LANGUAGE_INTERACTION_HINTS,
70
53
  };
71
54
  }
@@ -1,18 +1,44 @@
1
- import { formatLanguageUsageMessage, parseLanguageArgument } from '@robota-sdk/agent-framework';
1
+ import { selectAction } from '@robota-sdk/agent-core';
2
+ import {
3
+ buildLanguageCommandSubcommands,
4
+ formatLanguageUsageMessage,
5
+ parseLanguageArgument,
6
+ } from '@robota-sdk/agent-framework';
2
7
 
3
8
  import type { ICommandHostContext } from '@robota-sdk/agent-framework';
4
9
  import type { ICommandResult } from '@robota-sdk/agent-interface-transport';
5
10
 
6
- export function executeLanguageCommand(
7
- _context: ICommandHostContext,
11
+ /**
12
+ * Ask the user to pick a language (CMD-004 inline ask). Returns a validated language, or `undefined`
13
+ * when no interactive renderer is attached or the user cancelled — the caller then shows usage.
14
+ */
15
+ async function resolveLanguageViaAsk(context: ICommandHostContext): Promise<string | undefined> {
16
+ const ui = context.getUserInteraction?.();
17
+ if (!ui) return undefined;
18
+ const options = buildLanguageCommandSubcommands().map((sub) => ({
19
+ value: sub.name,
20
+ label: sub.name,
21
+ description: sub.description,
22
+ }));
23
+ const response = await ui.ask(selectAction('language', 'Select language', options));
24
+ const picked = response.type === 'answer' ? response.values[0] : undefined;
25
+ return picked === undefined ? undefined : parseLanguageArgument(picked);
26
+ }
27
+
28
+ export async function executeLanguageCommand(
29
+ context: ICommandHostContext,
8
30
  args: string,
9
- ): ICommandResult {
10
- const language = parseLanguageArgument(args);
31
+ ): Promise<ICommandResult> {
32
+ let language = parseLanguageArgument(args);
33
+
11
34
  if (language === undefined) {
12
- return {
13
- message: formatLanguageUsageMessage(),
14
- success: false,
15
- };
35
+ language = await resolveLanguageViaAsk(context);
36
+ if (language === undefined) {
37
+ return {
38
+ message: formatLanguageUsageMessage(),
39
+ success: false,
40
+ };
41
+ }
16
42
  }
17
43
 
18
44
  return {
@@ -140,4 +140,38 @@ describe('createModeCommandModule', () => {
140
140
  );
141
141
  expect(context.setPermissionMode).not.toHaveBeenCalled();
142
142
  });
143
+
144
+ it('asks the user to pick a mode when no arg is given and a renderer is attached (CMD-004)', async () => {
145
+ const executor = new SystemCommandExecutor([
146
+ ...(createModeCommandModule().systemCommands ?? []),
147
+ ]);
148
+ const context = createCommandHostContext();
149
+ const contextWithAsk: ICommandHostContext = {
150
+ ...context,
151
+ getUserInteraction: () => ({ ask: async () => ({ type: 'answer', values: ['plan'] }) }),
152
+ };
153
+
154
+ const result = await executor.execute('mode', contextWithAsk, '');
155
+
156
+ expect(result?.success).toBe(true);
157
+ expect(result?.message).toBe('Permission mode set to: plan');
158
+ expect(context.setPermissionMode).toHaveBeenCalledWith('plan');
159
+ });
160
+
161
+ it('reports the current mode when the user cancels the pick (CMD-004)', async () => {
162
+ const executor = new SystemCommandExecutor([
163
+ ...(createModeCommandModule().systemCommands ?? []),
164
+ ]);
165
+ const context = createCommandHostContext();
166
+ const contextWithAsk: ICommandHostContext = {
167
+ ...context,
168
+ getUserInteraction: () => ({ ask: async () => ({ type: 'cancelled' }) }),
169
+ };
170
+
171
+ const result = await executor.execute('mode', contextWithAsk, '');
172
+
173
+ expect(result?.success).toBe(true);
174
+ expect(result?.message).toBe('Current mode: default');
175
+ expect(context.setPermissionMode).not.toHaveBeenCalled();
176
+ });
143
177
  });
@@ -7,11 +7,7 @@ import {
7
7
  import { executeModeCommand } from './mode-command.js';
8
8
 
9
9
  import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
10
- import type {
11
- ICommand,
12
- ICommandInteractionHint,
13
- ICommandSource,
14
- } from '@robota-sdk/agent-interface-transport';
10
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-transport';
15
11
 
16
12
  export function createModeCommandEntry(): ICommand {
17
13
  return {
@@ -49,23 +45,10 @@ export class ModeCommandSource implements ICommandSource {
49
45
  }
50
46
  }
51
47
 
52
- const MODE_INTERACTION_HINTS: Record<string, ICommandInteractionHint> = {
53
- mode: {
54
- type: 'pick',
55
- getItems: () =>
56
- buildPermissionModeSubcommands().map((sub) => ({
57
- label: sub.name,
58
- value: sub.name,
59
- description: sub.description,
60
- })),
61
- },
62
- };
63
-
64
48
  export function createModeCommandModule(): ICommandModule {
65
49
  return {
66
50
  name: 'agent-command-mode',
67
51
  commandSources: [new ModeCommandSource()],
68
52
  systemCommands: [createModeSystemCommand()],
69
- interactionHints: MODE_INTERACTION_HINTS,
70
53
  };
71
54
  }
@@ -1,4 +1,6 @@
1
+ import { selectAction } from '@robota-sdk/agent-core';
1
2
  import {
3
+ buildPermissionModeSubcommands,
2
4
  formatInvalidPermissionModeMessage,
3
5
  isPermissionMode,
4
6
  parsePermissionModeArgument,
@@ -9,15 +11,36 @@ import {
9
11
  import type { ICommandHostContext } from '@robota-sdk/agent-framework';
10
12
  import type { ICommandResult } from '@robota-sdk/agent-interface-transport';
11
13
 
12
- export function executeModeCommand(context: ICommandHostContext, args: string): ICommandResult {
13
- const arg = parsePermissionModeArgument(args);
14
+ /**
15
+ * Ask the user to pick a permission mode (CMD-004 inline ask). Returns the chosen mode name, or
16
+ * `undefined` when no interactive renderer is attached or the user cancelled — the caller then reports
17
+ * the current mode instead of changing it (never a silent guess).
18
+ */
19
+ async function resolveModeViaAsk(context: ICommandHostContext): Promise<string | undefined> {
20
+ const ui = context.getUserInteraction?.();
21
+ if (!ui) return undefined;
22
+ const options = buildPermissionModeSubcommands().map((sub) => ({
23
+ value: sub.name,
24
+ label: sub.name,
25
+ description: sub.description,
26
+ }));
27
+ const response = await ui.ask(selectAction('mode', 'Select interaction mode', options));
28
+ return response.type === 'answer' ? response.values[0] : undefined;
29
+ }
30
+
31
+ export async function executeModeCommand(
32
+ context: ICommandHostContext,
33
+ args: string,
34
+ ): Promise<ICommandResult> {
35
+ let arg: string | undefined = parsePermissionModeArgument(args);
36
+
14
37
  if (arg === undefined) {
15
- const mode = readCommandPermissionMode(context);
16
- return {
17
- message: `Current mode: ${mode}`,
18
- success: true,
19
- data: { mode },
20
- };
38
+ arg = await resolveModeViaAsk(context);
39
+ if (arg === undefined) {
40
+ // No interactive answer — report the current mode without changing it.
41
+ const mode = readCommandPermissionMode(context);
42
+ return { message: `Current mode: ${mode}`, success: true, data: { mode } };
43
+ }
21
44
  }
22
45
 
23
46
  if (!isPermissionMode(arg)) {
@@ -135,4 +135,40 @@ describe('preset command module', () => {
135
135
  }
136
136
  expect(setActivePresetId).not.toHaveBeenCalled();
137
137
  });
138
+
139
+ it('CMD-004: asks the user to pick a preset when none is given, then switches', async () => {
140
+ const setActivePresetId = vi.fn();
141
+ const setPermissionMode = vi.fn();
142
+ const applyModelOptions = vi.fn();
143
+ const runtime = createSessionRuntime({
144
+ setActivePresetId,
145
+ setPermissionMode,
146
+ applyModelOptions,
147
+ });
148
+ const context = createCommandHostContext(runtime, {
149
+ getUserInteraction: () => ({
150
+ ask: async () => ({ type: 'answer', values: ['careful-reviewer'] }),
151
+ }),
152
+ });
153
+
154
+ const result = await executePresetCommand(context, '');
155
+
156
+ expect(result.success).toBe(true);
157
+ expect(result.message).toBe('Switched to preset: careful-reviewer');
158
+ expect(setActivePresetId).toHaveBeenCalledWith('careful-reviewer');
159
+ });
160
+
161
+ it('CMD-004: shows the preset list when the pick is cancelled', async () => {
162
+ const setActivePresetId = vi.fn();
163
+ const runtime = createSessionRuntime({ setActivePresetId });
164
+ const context = createCommandHostContext(runtime, {
165
+ getUserInteraction: () => ({ ask: async () => ({ type: 'cancelled' }) }),
166
+ });
167
+
168
+ const result = await executePresetCommand(context, '');
169
+
170
+ expect(result.success).toBe(true);
171
+ expect(result.message).toContain('Available presets:');
172
+ expect(setActivePresetId).not.toHaveBeenCalled();
173
+ });
138
174
  });
@@ -3,11 +3,7 @@ import { listPresets } from '@robota-sdk/agent-preset';
3
3
  import { executePresetCommand } from './preset-command.js';
4
4
 
5
5
  import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
6
- import type {
7
- ICommand,
8
- ICommandInteractionHint,
9
- ICommandSource,
10
- } from '@robota-sdk/agent-interface-transport';
6
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-transport';
11
7
 
12
8
  const PRESET_COMMAND_DESCRIPTION = 'List presets or switch the active preset';
13
9
  const PRESET_ARGUMENT_HINT = 'list | <preset-id>';
@@ -57,23 +53,10 @@ export class PresetCommandSource implements ICommandSource {
57
53
  }
58
54
  }
59
55
 
60
- const PRESET_INTERACTION_HINTS: Record<string, ICommandInteractionHint> = {
61
- preset: {
62
- type: 'pick',
63
- getItems: () =>
64
- buildPresetSubcommands().map((sub) => ({
65
- label: sub.name,
66
- value: sub.name,
67
- description: sub.description,
68
- })),
69
- },
70
- };
71
-
72
56
  export function createPresetCommandModule(): ICommandModule {
73
57
  return {
74
58
  name: 'agent-command-preset',
75
59
  commandSources: [new PresetCommandSource()],
76
60
  systemCommands: [createPresetSystemCommand()],
77
- interactionHints: PRESET_INTERACTION_HINTS,
78
61
  };
79
62
  }
@@ -1,3 +1,4 @@
1
+ import { selectAction } from '@robota-sdk/agent-core';
1
2
  import { applyPresetToSession } from '@robota-sdk/agent-framework';
2
3
  import { getPreset, listPresets, resolvePreset } from '@robota-sdk/agent-preset';
3
4
 
@@ -29,19 +30,47 @@ function formatUnknownPresetMessage(id: string): string {
29
30
  return `Unknown preset: ${id}. Available: ${ids}`;
30
31
  }
31
32
 
33
+ /** The `/preset` (or `/preset list`) listing result. */
34
+ function presetListResult(context: ICommandHostContext): ICommandResult {
35
+ const active = readActivePresetId(context);
36
+ return {
37
+ message: formatPresetList(active),
38
+ success: true,
39
+ data: { presets: listPresets(), active },
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Ask the user to pick a preset (CMD-004 inline ask). Returns the chosen id, or `undefined` when no
45
+ * interactive renderer is attached or the user cancelled — the caller then shows the preset list.
46
+ */
47
+ async function resolvePresetViaAsk(context: ICommandHostContext): Promise<string | undefined> {
48
+ const ui = context.getUserInteraction?.();
49
+ if (!ui) return undefined;
50
+ const options = listPresets().map((preset) => ({
51
+ value: preset.id,
52
+ label: preset.id,
53
+ description: preset.description,
54
+ }));
55
+ const response = await ui.ask(selectAction('preset', 'Select a preset', options));
56
+ return response.type === 'answer' ? response.values[0] : undefined;
57
+ }
58
+
32
59
  export async function executePresetCommand(
33
60
  context: ICommandHostContext,
34
61
  args: string,
35
62
  ): Promise<ICommandResult> {
36
- const id = args.trim().split(/\s+/)[0];
63
+ let id: string | undefined = args.trim().split(/\s+/)[0];
37
64
 
38
- if (id === undefined || id.length === 0 || id === 'list') {
39
- const active = readActivePresetId(context);
40
- return {
41
- message: formatPresetList(active),
42
- success: true,
43
- data: { presets: listPresets(), active },
44
- };
65
+ if (id === 'list') {
66
+ return presetListResult(context);
67
+ }
68
+
69
+ if (id === undefined || id.length === 0) {
70
+ id = await resolvePresetViaAsk(context);
71
+ if (id === undefined) {
72
+ return presetListResult(context);
73
+ }
45
74
  }
46
75
 
47
76
  if (getPreset(id) === undefined) {
@@ -8,6 +8,7 @@ import type {
8
8
  TProviderSettingsDocument,
9
9
  } from '@robota-sdk/agent-framework';
10
10
  import { createProviderCommandModule } from '../provider-command-module.js';
11
+ import { scriptedContext } from './scripted-interaction.js';
11
12
 
12
13
  const providerDefinitions: readonly IProviderDefinition[] = [
13
14
  {
@@ -62,7 +63,8 @@ function createExecutor(
62
63
  return new SystemCommandExecutor([...(module.systemCommands ?? [])]);
63
64
  }
64
65
 
65
- const session = {} as ICommandHostContext;
66
+ /** Context with no interactive renderer attached (headless/automation). */
67
+ const headlessContext = {} as ICommandHostContext;
66
68
 
67
69
  describe('org policy enforcement in provider commands', () => {
68
70
  afterEach(() => {
@@ -83,7 +85,7 @@ describe('org policy enforcement in provider commands', () => {
83
85
 
84
86
  const result = await createExecutor(adapter, orgPolicy).execute(
85
87
  'provider',
86
- session,
88
+ headlessContext,
87
89
  'switch openai',
88
90
  );
89
91
 
@@ -105,7 +107,7 @@ describe('org policy enforcement in provider commands', () => {
105
107
 
106
108
  const result = await createExecutor(adapter, orgPolicy).execute(
107
109
  'provider',
108
- session,
110
+ headlessContext,
109
111
  'switch openai',
110
112
  );
111
113
 
@@ -128,12 +130,14 @@ describe('org policy enforcement in provider commands', () => {
128
130
  });
129
131
  const orgPolicy: IOrgPolicy = { requireApiKeyFromEnv: true, adminContact: 'sec@example.com' };
130
132
 
131
- const listed = await createExecutor(adapter, orgPolicy).execute('provider', session, 'list');
132
- const selected = await listed?.interaction?.submit('anthropic');
133
- const editRequested = await selected?.interaction?.submit('edit');
134
133
  // Anthropic has 2 setup steps: apiKey then model
135
- const modelPrompt = await editRequested?.interaction?.submit('sk-plaintext-key');
136
- const completed = await modelPrompt?.interaction?.submit('claude-sonnet-4-6');
134
+ const { context } = scriptedContext([
135
+ { type: 'answer', values: ['anthropic'] },
136
+ { type: 'answer', values: ['edit'] },
137
+ { type: 'answer', values: [], text: 'sk-plaintext-key' },
138
+ { type: 'answer', values: [], text: 'claude-sonnet-4-6' },
139
+ ]);
140
+ const completed = await createExecutor(adapter, orgPolicy).execute('provider', context, 'list');
137
141
 
138
142
  expect(completed?.success).toBe(false);
139
143
  expect(completed?.message).toContain('environment variable references');
@@ -154,11 +158,13 @@ describe('org policy enforcement in provider commands', () => {
154
158
  });
155
159
  const orgPolicy: IOrgPolicy = { requireApiKeyFromEnv: true };
156
160
 
157
- const listed = await createExecutor(adapter, orgPolicy).execute('provider', session, 'list');
158
- const selected = await listed?.interaction?.submit('anthropic');
159
- const editRequested = await selected?.interaction?.submit('edit');
160
- const modelPrompt = await editRequested?.interaction?.submit('$ENV:ORG_TEST_KEY');
161
- const completed = await modelPrompt?.interaction?.submit('claude-opus-4-5');
161
+ const { context } = scriptedContext([
162
+ { type: 'answer', values: ['anthropic'] },
163
+ { type: 'answer', values: ['edit'] },
164
+ { type: 'answer', values: [], text: '$ENV:ORG_TEST_KEY' },
165
+ { type: 'answer', values: [], text: 'claude-opus-4-5' },
166
+ ]);
167
+ const completed = await createExecutor(adapter, orgPolicy).execute('provider', context, 'list');
162
168
 
163
169
  expect(completed?.success).toBe(true);
164
170
  });