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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +71 -62
  3. package/dist/node/index.cjs +75 -72
  4. package/dist/node/index.d.cts +37 -45
  5. package/dist/node/index.d.cts.map +1 -1
  6. package/dist/node/index.d.ts +37 -45
  7. package/dist/node/index.d.ts.map +1 -1
  8. package/dist/node/index.js +78 -75
  9. package/dist/node/index.js.map +1 -1
  10. package/package.json +9 -8
  11. package/src/agent/__tests__/agent-command.test.ts +62 -0
  12. package/src/agent/agent-command-parser.ts +2 -2
  13. package/src/agent/agent-command.ts +20 -0
  14. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +2 -2
  15. package/src/default/__tests__/default-command-modules.test.ts +5 -4
  16. package/src/default/__tests__/model-exposure.test.ts +2 -0
  17. package/src/default/default-command-modules.ts +8 -0
  18. package/src/effort/effort-command.test.ts +65 -2
  19. package/src/effort/effort-command.ts +19 -5
  20. package/src/git/__tests__/fake-git-port.ts +9 -2
  21. package/src/git/git-command-module.ts +3 -4
  22. package/src/git/git-commit.ts +6 -3
  23. package/src/git/index.ts +4 -1
  24. package/src/goal/goal-command.ts +3 -1
  25. package/src/index.ts +6 -0
  26. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +131 -0
  27. package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
  28. package/src/mcp-activation/mcp-activation-command.ts +54 -1
  29. package/src/memory/__tests__/memory-command-module.test.ts +40 -2
  30. package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
  31. package/src/memory/memory-command-module.ts +4 -2
  32. package/src/memory/memory-command.ts +42 -2
  33. package/src/mode/__tests__/mode-command-module.test.ts +40 -0
  34. package/src/mode/mode-command.ts +1 -1
  35. package/src/model/__tests__/model-command-module.test.ts +80 -0
  36. package/src/model/__tests__/model-command.test.ts +240 -0
  37. package/src/model/index.ts +6 -0
  38. package/src/model/model-command-module.ts +56 -0
  39. package/src/model/model-command.ts +223 -0
  40. package/src/plugin/__tests__/plugin-command-module.test.ts +81 -1
  41. package/src/plugin/plugin-command.ts +47 -4
  42. package/src/provider/__tests__/org-policy.test.ts +4 -0
  43. package/src/provider/__tests__/provider-command-module.test.ts +276 -14
  44. package/src/provider/__tests__/scripted-interaction.ts +12 -2
  45. package/src/provider/provider-command-execution.ts +54 -8
  46. package/src/provider/provider-command-profile-lifecycle.ts +48 -2
  47. package/src/provider/provider-command-profile-operations.ts +14 -0
  48. package/src/provider/provider-command-profile.ts +7 -4
  49. package/src/provider/provider-command-setup.ts +14 -1
  50. package/src/rewind/__tests__/rewind-command-module.test.ts +2 -1
  51. package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
  52. package/src/schedule/__tests__/loop-command.test.ts +238 -4
  53. package/src/schedule/loop-command.ts +95 -3
  54. package/src/schedule/schedule-command-module.ts +8 -4
  55. package/src/settings/settings-command-module.ts +2 -2
  56. package/src/git/__tests__/git-diff.test.ts +0 -125
  57. package/src/git/__tests__/git-process.test.ts +0 -221
  58. package/src/git/__tests__/git-status.test.ts +0 -117
  59. package/src/git/git-diff.ts +0 -129
  60. package/src/git/git-process.ts +0 -169
  61. package/src/git/git-status.ts +0 -134
@@ -43,10 +43,13 @@ const MONITOR_MODEL_DESCRIPTION =
43
43
  'The command is allowed, refused or asked about by the same permission rules as a shell tool ' +
44
44
  'call. Returns the monitor task id, or a refusal naming what the user must allow.';
45
45
  const LOOP_MODEL_DESCRIPTION =
46
- 'Repeat a prompt on a fixed cadence (`<N><s|m|h|d> [prompt]`) or self-paced (bare or prompt ' +
47
- 'only: each iteration chooses its next delay or stops). Use it when the user asks for recurring ' +
48
- 'work in this session. `list` shows active loops; `stop <id>` ends one. Returns the loop id and ' +
49
- 'its next fire time, or the loop list.';
46
+ 'Repeat a prompt on a fixed cadence (`<N><s|m|h|d> <prompt>`) or self-paced (`<prompt>`: each ' +
47
+ 'iteration chooses its next delay or stops). Use it when the user asks for recurring work in ' +
48
+ 'this session, and always give your own prompt: any form that would fall back to the host\'s ' +
49
+ 'default maintenance prompt — bare `/loop` or an interval alone (`<N><s|m|h|d>`) — needs a ' +
50
+ 'person to confirm it and is always refused for you, whether or not one is attached. `list` ' +
51
+ 'shows active loops; `stop <id>` ends one, even mid-turn. Returns the loop id and its next fire ' +
52
+ 'time, the loop list, or a refusal naming the command to suggest.';
50
53
 
51
54
  export function createScheduleCommandEntry(): ICommand {
52
55
  return {
@@ -154,6 +157,7 @@ function createLoopSystemCommand(options: ILoopCommandOptions): ISystemCommand {
154
157
  (taskId, reason) => context.cancelBackgroundTask(taskId, reason),
155
158
  args,
156
159
  options,
160
+ { ui: context.getUserInteraction(), source: context.getCommandInvocationSource() },
157
161
  ),
158
162
  };
159
163
  }
@@ -5,9 +5,9 @@ export function createSettingsCommandEntry(): ICommand {
5
5
  return {
6
6
  name: 'settings',
7
7
  displayName: 'Settings',
8
- description: 'Open transport settings — enable/disable transports and configure options',
8
+ description: 'Open settings — language, output style, preset, permissions and more',
9
9
  source: 'settings',
10
- // User-only: transport settings screen; UI-only.
10
+ // User-only: opens a screen; nothing here is a model action.
11
11
  modelInvocable: false,
12
12
  };
13
13
  }
@@ -1,125 +0,0 @@
1
- /** BEHAVIOR-2437 TC-02 — `/git diff`: the closed grammar and the exact argv sequence per branch. */
2
- import { describe, expect, it } from 'vitest';
3
-
4
- import { executeGitDiff, gitDiffArgv, parseGitDiffArgs, GIT_DIFF_USAGE } from '../git-diff.js';
5
-
6
- import { exited, fakeGitPort } from './fake-git-port.js';
7
-
8
- const revParse = (rev: string): string =>
9
- `rev-parse --verify --quiet --end-of-options ${rev}^{commit}`;
10
-
11
- describe('parseGitDiffArgs', () => {
12
- it('accepts the four forms and the path list', () => {
13
- expect(parseGitDiffArgs([])).toEqual({
14
- ok: true,
15
- args: { target: { kind: 'worktree' }, paths: [] },
16
- });
17
- expect(parseGitDiffArgs(['--staged'])).toMatchObject({
18
- ok: true,
19
- args: { target: { kind: 'staged' } },
20
- });
21
- expect(parseGitDiffArgs(['HEAD~1'])).toMatchObject({
22
- ok: true,
23
- args: { target: { kind: 'revision', revision: 'HEAD~1' } },
24
- });
25
- expect(parseGitDiffArgs(['main..feature', '--', 'p', 'q'])).toEqual({
26
- ok: true,
27
- args: { target: { kind: 'range', from: 'main', to: 'feature' }, paths: ['p', 'q'] },
28
- });
29
- });
30
-
31
- it('refuses every other dash token with the usage line', () => {
32
- for (const token of ['--output=/tmp/x', '-p', '--cached', '-U3']) {
33
- const parsed = parseGitDiffArgs([token]);
34
- expect(parsed.ok).toBe(false);
35
- if (!parsed.ok) {
36
- expect(parsed.message).toContain(token);
37
- expect(parsed.message).toContain(GIT_DIFF_USAGE);
38
- }
39
- }
40
- });
41
-
42
- it('refuses a malformed range, a bare `--`, and extra positionals', () => {
43
- expect(parseGitDiffArgs(['a..'])).toMatchObject({ ok: false });
44
- expect(parseGitDiffArgs(['a...b'])).toMatchObject({ ok: false });
45
- expect(parseGitDiffArgs(['--'])).toMatchObject({ ok: false });
46
- expect(parseGitDiffArgs(['a', 'b'])).toMatchObject({ ok: false });
47
- });
48
- });
49
-
50
- describe('gitDiffArgv', () => {
51
- it('places --end-of-options before revisions and -- before paths', () => {
52
- expect(gitDiffArgv({ target: { kind: 'worktree' }, paths: [] })).toEqual(['diff']);
53
- expect(gitDiffArgv({ target: { kind: 'staged' }, paths: [] })).toEqual(['diff', '--staged']);
54
- expect(gitDiffArgv({ target: { kind: 'revision', revision: '-p' }, paths: ['-p'] })).toEqual([
55
- 'diff',
56
- '--end-of-options',
57
- '-p',
58
- '--',
59
- '-p',
60
- ]);
61
- expect(
62
- gitDiffArgv({ target: { kind: 'range', from: 'a', to: 'b' }, paths: ['p', 'q'] }),
63
- ).toEqual(['diff', '--end-of-options', 'a..b', '--', 'p', 'q']);
64
- });
65
- });
66
-
67
- describe('executeGitDiff', () => {
68
- it('bare and --staged run one diff call each', async () => {
69
- const port = fakeGitPort({ diff: exited('+x\n'), 'diff --staged': exited('') });
70
- expect((await executeGitDiff(port, '/r', '')).message).toBe('+x');
71
- expect((await executeGitDiff(port, '/r', '--staged')).message).toBe('No differences.');
72
- expect(port.calls).toEqual([['diff'], ['diff', '--staged']]);
73
- });
74
-
75
- it('verifies one revision, then diffs, with a trailing path list', async () => {
76
- const port = fakeGitPort({
77
- [revParse('HEAD~1')]: exited('abc\n'),
78
- 'diff --end-of-options HEAD~1 -- p q': exited('+y\n'),
79
- });
80
- const result = await executeGitDiff(port, '/r', 'HEAD~1 -- p q');
81
- expect(result.success).toBe(true);
82
- expect(port.calls).toEqual([
83
- ['rev-parse', '--verify', '--quiet', '--end-of-options', 'HEAD~1^{commit}'],
84
- ['diff', '--end-of-options', 'HEAD~1', '--', 'p', 'q'],
85
- ]);
86
- });
87
-
88
- it('verifies both ends of a range before one diff call', async () => {
89
- const port = fakeGitPort({
90
- [revParse('a')]: exited('1\n'),
91
- [revParse('b')]: exited('2\n'),
92
- 'diff --end-of-options a..b': exited(''),
93
- });
94
- await executeGitDiff(port, '/r', 'a..b');
95
- expect(port.calls.map((c) => c.join(' '))).toEqual([
96
- revParse('a'),
97
- revParse('b'),
98
- 'diff --end-of-options a..b',
99
- ]);
100
- });
101
-
102
- it('refuses an unknown revision by name with no diff call', async () => {
103
- const port = fakeGitPort({ [revParse('nosuchrev')]: exited('', 1) });
104
- const result = await executeGitDiff(port, '/r', 'nosuchrev');
105
- expect(result.success).toBe(false);
106
- expect(result.message).toContain('nosuchrev');
107
- expect(port.calls).toHaveLength(1);
108
- });
109
-
110
- it('refuses a flag with the usage line and no call at all', async () => {
111
- const port = fakeGitPort({});
112
- const result = await executeGitDiff(port, '/r', '--output=/tmp/x');
113
- expect(result.success).toBe(false);
114
- expect(result.message).toContain('--output=/tmp/x');
115
- expect(result.message).toContain('--staged');
116
- expect(port.calls).toEqual([]);
117
- });
118
-
119
- it('reports a diff failure with git’s stderr', async () => {
120
- const port = fakeGitPort({ diff: exited('', 129, 'fatal: bad\n') });
121
- const result = await executeGitDiff(port, '/r', '');
122
- expect(result.success).toBe(false);
123
- expect(result.message).toContain('fatal: bad');
124
- });
125
- });
@@ -1,221 +0,0 @@
1
- /**
2
- * BEHAVIOR-2437 TC-04 — the seam against a REAL temporary repository: argv-only (metacharacters are
3
- * data), exit codes as data, stdin closed (a hook that reads it gets EOF), timeout / abort / missing
4
- * executable / oversized output as typed failures, and the hook-nesting environment policy.
5
- */
6
- import { execFileSync } from 'node:child_process';
7
- import { chmodSync, mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
8
- import { tmpdir } from 'node:os';
9
- import { join } from 'node:path';
10
-
11
- import { afterEach, describe, expect, it } from 'vitest';
12
-
13
- import { createGitProcess, gitEnvironment, GIT_ENV_DENYLIST } from '../git-process.js';
14
-
15
- const dirs: string[] = [];
16
- afterEach(() => {
17
- for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
18
- });
19
-
20
- function tempDir(prefix: string): string {
21
- const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
22
- dirs.push(dir);
23
- return dir;
24
- }
25
-
26
- /** A hermetic environment: no user/system git config, no inherited hook-nesting variables. */
27
- function hermeticEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
28
- const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, HOME: tempDir('git-home-') };
29
- env.GIT_CONFIG_NOSYSTEM = '1';
30
- env.GIT_CONFIG_GLOBAL = join(env.HOME ?? '', 'gitconfig');
31
- writeFileSync(env.GIT_CONFIG_GLOBAL, '[commit]\n\tgpgsign = false\n');
32
- return { ...env, ...extra };
33
- }
34
-
35
- function git(cwd: string, env: NodeJS.ProcessEnv, ...args: string[]): string {
36
- return execFileSync('git', args, {
37
- cwd,
38
- env,
39
- encoding: 'utf8',
40
- stdio: ['ignore', 'pipe', 'pipe'],
41
- });
42
- }
43
-
44
- function initRepo(env: NodeJS.ProcessEnv, identity = true): string {
45
- const repo = tempDir('git-seam-');
46
- git(repo, env, 'init', '-q', '-b', 'main');
47
- if (identity) {
48
- git(repo, env, 'config', 'user.name', 'Seam Test');
49
- git(repo, env, 'config', 'user.email', 'seam@example.com');
50
- }
51
- writeFileSync(join(repo, 'base.txt'), 'base\n');
52
- git(repo, env, 'add', '--', 'base.txt');
53
- if (identity) git(repo, env, 'commit', '-q', '-m', 'chore: base');
54
- return repo;
55
- }
56
-
57
- function installHook(repo: string, name: string, body: string): void {
58
- const hooks = join(repo, '.git', 'hooks');
59
- mkdirSync(hooks, { recursive: true });
60
- const path = join(hooks, name);
61
- writeFileSync(path, `#!/bin/sh\n${body}\n`);
62
- chmodSync(path, 0o755);
63
- }
64
-
65
- describe('gitEnvironment', () => {
66
- it('strips the repository-redirecting variables and preserves identity and config by name', () => {
67
- const source: NodeJS.ProcessEnv = {
68
- PATH: '/usr/bin',
69
- XDG_CONFIG_HOME: '/x',
70
- GIT_CONFIG_GLOBAL: '/g',
71
- GIT_AUTHOR_NAME: 'a',
72
- GIT_AUTHOR_EMAIL: 'a@x',
73
- GIT_COMMITTER_NAME: 'c',
74
- GIT_COMMITTER_EMAIL: 'c@x',
75
- EMAIL: 'e@x',
76
- GPG_TTY: '/dev/tty',
77
- GNUPGHOME: '/gnupg',
78
- SSH_AUTH_SOCK: '/sock',
79
- };
80
- for (const key of GIT_ENV_DENYLIST) source[key] = `/elsewhere/${key}`;
81
- const env = gitEnvironment(source);
82
- for (const key of GIT_ENV_DENYLIST) expect(env).not.toHaveProperty(key);
83
- for (const key of [
84
- 'PATH',
85
- 'XDG_CONFIG_HOME',
86
- 'GIT_CONFIG_GLOBAL',
87
- 'GIT_AUTHOR_NAME',
88
- 'GIT_AUTHOR_EMAIL',
89
- 'GIT_COMMITTER_NAME',
90
- 'GIT_COMMITTER_EMAIL',
91
- 'EMAIL',
92
- 'GPG_TTY',
93
- 'GNUPGHOME',
94
- 'SSH_AUTH_SOCK',
95
- ]) {
96
- expect(env[key]).toBe(source[key]);
97
- }
98
- });
99
- });
100
-
101
- describe('createGitProcess against a real repository', () => {
102
- it('returns exit codes as data: diff --cached --quiet is 1 when staged, 0 when not', async () => {
103
- const env = hermeticEnv();
104
- const repo = initRepo(env);
105
- const port = createGitProcess({ env });
106
- expect(await port.run(['diff', '--cached', '--quiet'], { cwd: repo })).toMatchObject({
107
- kind: 'exited',
108
- exitCode: 0,
109
- });
110
- writeFileSync(join(repo, 'new.txt'), 'new\n');
111
- git(repo, env, 'add', '--', 'new.txt');
112
- expect(await port.run(['diff', '--cached', '--quiet'], { cwd: repo })).toMatchObject({
113
- kind: 'exited',
114
- exitCode: 1,
115
- });
116
- });
117
-
118
- it('passes a subject and a path with shell metacharacters as ONE element each (shell: false)', async () => {
119
- const env = hermeticEnv();
120
- const repo = initRepo(env);
121
- const port = createGitProcess({ env });
122
- const path = 'a;b c$(touch pwned-path).txt';
123
- const subject = 'feat: $(touch pwned) ; echo "quoted" `id` | cat > /dev/null';
124
- writeFileSync(join(repo, path), 'x\n');
125
- expect(await port.run(['add', '--', path], { cwd: repo })).toMatchObject({ exitCode: 0 });
126
- expect(await port.run(['commit', '-m', subject], { cwd: repo })).toMatchObject({ exitCode: 0 });
127
- const log = await port.run(['log', '-1', '--format=%s'], { cwd: repo });
128
- expect(log).toMatchObject({ kind: 'exited', exitCode: 0, stdout: `${subject}\n` });
129
- const status = await port.run(['status', '--porcelain=v2', '-z'], { cwd: repo });
130
- expect(status).toMatchObject({ kind: 'exited', exitCode: 0 });
131
- // Neither substitution ran: the tree holds only what git was told, and nothing named pwned.
132
- if (status.kind === 'exited') expect(status.stdout).not.toContain('pwned');
133
- const shown = await port.run(['show', '--name-only', '--format=', 'HEAD'], { cwd: repo });
134
- if (shown.kind === 'exited') expect(shown.stdout.trim()).toBe(path);
135
- });
136
-
137
- it('closes stdin: a pre-commit hook that reads it sees EOF instead of hanging', async () => {
138
- const env = hermeticEnv();
139
- const repo = initRepo(env);
140
- installHook(repo, 'pre-commit', 'cat > /dev/null\nexit 0');
141
- writeFileSync(join(repo, 'new.txt'), 'new\n');
142
- git(repo, env, 'add', '--', 'new.txt');
143
- const port = createGitProcess({ env });
144
- const outcome = await port.run(['commit', '-m', 'feat: hooked'], {
145
- cwd: repo,
146
- timeoutMs: 5000,
147
- });
148
- expect(outcome).toMatchObject({ kind: 'exited', exitCode: 0 });
149
- });
150
-
151
- it('kills a child that exceeds the timeout and reports failed: timeout', async () => {
152
- const env = hermeticEnv();
153
- const repo = initRepo(env);
154
- installHook(repo, 'pre-commit', 'exec >/dev/null 2>&1\nsleep 5');
155
- writeFileSync(join(repo, 'new.txt'), 'new\n');
156
- git(repo, env, 'add', '--', 'new.txt');
157
- const port = createGitProcess({ env });
158
- const outcome = await port.run(['commit', '-m', 'feat: slow'], { cwd: repo, timeoutMs: 300 });
159
- expect(outcome).toMatchObject({ kind: 'failed', reason: 'timeout' });
160
- expect(git(repo, env, 'log', '--oneline').trim().split('\n')).toHaveLength(1);
161
- });
162
-
163
- it('reports an aborted signal as failed: aborted', async () => {
164
- const env = hermeticEnv();
165
- const repo = initRepo(env);
166
- installHook(repo, 'pre-commit', 'exec >/dev/null 2>&1\nsleep 5');
167
- writeFileSync(join(repo, 'new.txt'), 'new\n');
168
- git(repo, env, 'add', '--', 'new.txt');
169
- const port = createGitProcess({ env });
170
- const controller = new AbortController();
171
- const pending = port.run(['commit', '-m', 'feat: aborted'], {
172
- cwd: repo,
173
- timeoutMs: 10_000,
174
- signal: controller.signal,
175
- });
176
- setTimeout(() => controller.abort(), 100);
177
- expect(await pending).toMatchObject({ kind: 'failed', reason: 'aborted' });
178
- });
179
-
180
- it('reports a missing executable as failed: not-found', async () => {
181
- const env = hermeticEnv();
182
- const repo = initRepo(env);
183
- const port = createGitProcess({ env, executable: join(repo, 'no-such-git') });
184
- const outcome = await port.run(['status'], { cwd: repo });
185
- expect(outcome).toMatchObject({ kind: 'failed', reason: 'not-found' });
186
- if (outcome.kind === 'failed') expect(outcome.detail).toContain('ENOENT');
187
- });
188
-
189
- it('reports output past 16 MiB as failed: output-too-large', async () => {
190
- const env = hermeticEnv();
191
- const dir = tempDir('git-big-');
192
- const fake = join(dir, 'big-git');
193
- writeFileSync(fake, '#!/bin/sh\nhead -c 17000000 /dev/zero\n');
194
- chmodSync(fake, 0o755);
195
- const port = createGitProcess({ env, executable: fake });
196
- const outcome = await port.run(['status'], { cwd: dir, timeoutMs: 20_000 });
197
- expect(outcome).toMatchObject({ kind: 'failed', reason: 'output-too-large' });
198
- });
199
-
200
- it('a hook-exported GIT_DIR does not redirect the child; GIT_CONFIG_GLOBAL still carries identity', async () => {
201
- const env = hermeticEnv();
202
- const other = initRepo(env);
203
- const repo = initRepo(env, false);
204
- writeFileSync(
205
- env.GIT_CONFIG_GLOBAL ?? '',
206
- '[commit]\n\tgpgsign = false\n[user]\n\tname = Global Identity\n\temail = global@example.com\n',
207
- );
208
- // A hook-style environment: GIT_DIR points at ANOTHER repository.
209
- const port = createGitProcess({
210
- env: { ...env, GIT_DIR: join(other, '.git'), GIT_WORK_TREE: other },
211
- });
212
- const top = await port.run(['rev-parse', '--show-toplevel'], { cwd: repo });
213
- expect(top).toMatchObject({ kind: 'exited', exitCode: 0, stdout: `${repo}\n` });
214
- const commit = await port.run(['commit', '-m', 'feat: identity'], { cwd: repo });
215
- expect(commit).toMatchObject({ kind: 'exited', exitCode: 0 });
216
- const author = await port.run(['log', '-1', '--format=%an <%ae>'], { cwd: repo });
217
- expect(author).toMatchObject({ stdout: 'Global Identity <global@example.com>\n' });
218
- // The other repository is untouched: still exactly its base commit.
219
- expect(git(other, env, 'log', '--oneline').trim().split('\n')).toHaveLength(1);
220
- });
221
- });
@@ -1,117 +0,0 @@
1
- /** BEHAVIOR-2437 TC-01 — `/git status`: one porcelain-v2 call, parsed into the four groups. */
2
- import { describe, expect, it } from 'vitest';
3
-
4
- import { executeGitStatus, formatGitStatus, parseStatusPorcelainV2 } from '../git-status.js';
5
-
6
- import { exited, fakeGitPort } from './fake-git-port.js';
7
-
8
- const H = '0000000000000000000000000000000000000000';
9
- const rec = (parts: string[]): string => `${parts.join(' ')}\0`;
10
- /** Captured from a real `git status --porcelain=v2 -z --branch` run, plus the edge paths. */
11
- const FIXTURE =
12
- rec(['#', 'branch.oid', H]) +
13
- rec(['#', 'branch.head', 'main']) +
14
- rec(['1', 'MM', 'N...', '100644', '100644', '100644', H, H, 'a.txt']) +
15
- rec(['2', 'R.', 'N...', '100644', '100644', '100644', H, H, 'R100', 'new.txt']) +
16
- 'old.txt\0' +
17
- rec(['1', '.M', 'N...', '100644', '100644', '100644', H, H, 'sp ace.txt']) +
18
- rec(['1', 'A.', 'N...', '000000', '100644', '100644', H, H, 'quo"te.txt']) +
19
- rec(['1', 'A.', 'N...', '000000', '100644', '100644', H, H, '한글 파일.txt']) +
20
- rec(['?', 'untracked.txt']);
21
-
22
- describe('parseStatusPorcelainV2', () => {
23
- it('yields the branch and the staged, unstaged and untracked sets, one element per path', () => {
24
- const summary = parseStatusPorcelainV2(FIXTURE);
25
- expect(summary.branch).toBe('main');
26
- expect(summary.unborn).toBe(false);
27
- // The MM entry is in BOTH sets; the rename pair stays a pair; each edge path is one element.
28
- expect(summary.staged).toEqual([
29
- 'a.txt',
30
- 'new.txt (from old.txt)',
31
- 'quo"te.txt',
32
- '한글 파일.txt',
33
- ]);
34
- expect(summary.unstaged).toEqual(['a.txt', 'sp ace.txt']);
35
- expect(summary.untracked).toEqual(['untracked.txt']);
36
- expect(summary.conflicted).toEqual([]);
37
- });
38
-
39
- it('reads an unborn branch and a detached HEAD', () => {
40
- expect(
41
- parseStatusPorcelainV2(
42
- rec(['#', 'branch.oid', '(initial)']) + rec(['#', 'branch.head', 'main']),
43
- ),
44
- ).toMatchObject({ branch: 'main', unborn: true });
45
- expect(parseStatusPorcelainV2(rec(['#', 'branch.head', '(detached)'])).branch).toBe(
46
- '(detached)',
47
- );
48
- });
49
-
50
- it('collects unmerged entries separately', () => {
51
- const fixture = rec([
52
- 'u',
53
- 'UU',
54
- 'N...',
55
- '100644',
56
- '100644',
57
- '100644',
58
- '100644',
59
- H,
60
- H,
61
- H,
62
- 'c.txt',
63
- ]);
64
- expect(parseStatusPorcelainV2(fixture).conflicted).toEqual(['c.txt']);
65
- });
66
- });
67
-
68
- describe('formatGitStatus', () => {
69
- it('prints the branch and each group with its count', () => {
70
- expect(formatGitStatus(parseStatusPorcelainV2(FIXTURE))).toBe(
71
- [
72
- 'On branch main',
73
- 'staged (4): a.txt, new.txt (from old.txt), quo"te.txt, 한글 파일.txt',
74
- 'unstaged (2): a.txt, sp ace.txt',
75
- 'untracked (1): untracked.txt',
76
- ].join('\n'),
77
- );
78
- });
79
- });
80
-
81
- describe('executeGitStatus', () => {
82
- it('runs exactly one porcelain-v2 call and reports the summary', async () => {
83
- const port = fakeGitPort({ 'status --porcelain=v2 -z --branch': exited(FIXTURE) });
84
- const result = await executeGitStatus(port, '/repo');
85
- expect(port.calls).toEqual([['status', '--porcelain=v2', '-z', '--branch']]);
86
- expect(result.success).toBe(true);
87
- expect(result.message).toContain('On branch main');
88
- expect(result.data).toMatchObject({ git: 'status', untracked: ['untracked.txt'] });
89
- });
90
-
91
- it('reports git’s own reason on exit 128 and makes no second call', async () => {
92
- const port = fakeGitPort({
93
- 'status --porcelain=v2 -z --branch': exited(
94
- '',
95
- 128,
96
- 'fatal: not a git repository (or any of the parent directories): .git\n',
97
- ),
98
- });
99
- const result = await executeGitStatus(port, '/not-a-repo');
100
- expect(result.success).toBe(false);
101
- expect(result.message).toContain('fatal: not a git repository');
102
- expect(port.calls).toHaveLength(1);
103
- });
104
-
105
- it('reports a spawn failure by its reason', async () => {
106
- const port = fakeGitPort({
107
- 'status --porcelain=v2 -z --branch': {
108
- kind: 'failed',
109
- reason: 'not-found',
110
- detail: 'git could not be run (ENOENT)',
111
- },
112
- });
113
- const result = await executeGitStatus(port, '/repo');
114
- expect(result.success).toBe(false);
115
- expect(result.message).toContain('ENOENT');
116
- });
117
- });
@@ -1,129 +0,0 @@
1
- /**
2
- * BEHAVIOR-2437: `/git diff` — a closed grammar, not a pass-through for git flags.
3
- *
4
- * `/git diff` (unstaged) · `/git diff --staged` (index vs HEAD) · `/git diff <rev>` (worktree vs rev)
5
- * · `/git diff <a>..<b>` (two revisions), each optionally followed by `-- <path>…`. Every revision is
6
- * verified with `rev-parse --verify --quiet --end-of-options <rev>^{commit}` before any diff runs;
7
- * any other `-` token is refused, because a flag is exactly how an argv element becomes an option.
8
- * In the argv git receives, `--end-of-options` precedes every user revision and `--` every path.
9
- */
10
- import { gitFailureMessage } from './git-process.js';
11
-
12
- import type { IGitProcessPort } from './git-process.js';
13
- import type { ICommandResult } from '@robota-sdk/agent-interface-command';
14
-
15
- export const GIT_DIFF_USAGE =
16
- 'Usage: /git diff [--staged | <rev> | <a>..<b>] [-- <path> ...] — other git flags are not accepted here.';
17
-
18
- export type TGitDiffTarget =
19
- | { kind: 'worktree' }
20
- | { kind: 'staged' }
21
- | { kind: 'revision'; revision: string }
22
- | { kind: 'range'; from: string; to: string };
23
-
24
- export interface IGitDiffArgs {
25
- target: TGitDiffTarget;
26
- paths: readonly string[];
27
- }
28
-
29
- export type TParseGitDiffArgs = { ok: true; args: IGitDiffArgs } | { ok: false; message: string };
30
-
31
- function refuse(reason: string): TParseGitDiffArgs {
32
- return { ok: false, message: `${reason}\n${GIT_DIFF_USAGE}` };
33
- }
34
-
35
- export function parseGitDiffArgs(tokens: readonly string[]): TParseGitDiffArgs {
36
- const separator = tokens.indexOf('--');
37
- const head = separator === -1 ? tokens : tokens.slice(0, separator);
38
- const paths = separator === -1 ? [] : tokens.slice(separator + 1);
39
- if (separator !== -1 && paths.length === 0) return refuse('`--` must be followed by a path.');
40
-
41
- const flag = head.find((token) => token.startsWith('-') && token !== '--staged');
42
- if (flag !== undefined) return refuse(`\`${flag}\` is not accepted.`);
43
- if (head.length > 1) return refuse(`Too many arguments: ${head.join(' ')}.`);
44
-
45
- const token = head[0];
46
- if (token === undefined) return { ok: true, args: { target: { kind: 'worktree' }, paths } };
47
- if (token === '--staged') return { ok: true, args: { target: { kind: 'staged' }, paths } };
48
- if (token.includes('..')) {
49
- if (token.includes('...')) return refuse(`\`${token}\` is not a \`<a>..<b>\` range.`);
50
- const parts = token.split('..');
51
- const [from, to] = parts;
52
- if (parts.length !== 2 || !from || !to) {
53
- return refuse(`\`${token}\` is not a \`<a>..<b>\` range.`);
54
- }
55
- return { ok: true, args: { target: { kind: 'range', from, to }, paths } };
56
- }
57
- return { ok: true, args: { target: { kind: 'revision', revision: token }, paths } };
58
- }
59
-
60
- /** The revisions a target names, in the order they are verified. */
61
- function revisionsOf(target: TGitDiffTarget): readonly string[] {
62
- switch (target.kind) {
63
- case 'revision':
64
- return [target.revision];
65
- case 'range':
66
- return [target.from, target.to];
67
- default:
68
- return [];
69
- }
70
- }
71
-
72
- /** The `diff` argv for a parsed target, `--end-of-options` before revisions and `--` before paths. */
73
- export function gitDiffArgv(args: IGitDiffArgs): readonly string[] {
74
- const argv = ['diff'];
75
- switch (args.target.kind) {
76
- case 'staged':
77
- argv.push('--staged');
78
- break;
79
- case 'revision':
80
- argv.push('--end-of-options', args.target.revision);
81
- break;
82
- case 'range':
83
- argv.push('--end-of-options', `${args.target.from}..${args.target.to}`);
84
- break;
85
- default:
86
- break;
87
- }
88
- if (args.paths.length > 0) argv.push('--', ...args.paths);
89
- return argv;
90
- }
91
-
92
- function gitRevParseArgv(revision: string): readonly string[] {
93
- return ['rev-parse', '--verify', '--quiet', '--end-of-options', `${revision}^{commit}`];
94
- }
95
-
96
- function tokenizeGitArgs(args: string): string[] {
97
- return args.split(/\s+/).filter((token) => token.length > 0);
98
- }
99
-
100
- export async function executeGitDiff(
101
- port: IGitProcessPort,
102
- cwd: string,
103
- args: string,
104
- ): Promise<ICommandResult> {
105
- const parsed = parseGitDiffArgs(tokenizeGitArgs(args));
106
- if (!parsed.ok) return { success: false, message: parsed.message };
107
-
108
- for (const revision of revisionsOf(parsed.args.target)) {
109
- const outcome = await port.run(gitRevParseArgv(revision), { cwd });
110
- if (outcome.kind === 'failed') {
111
- return { success: false, message: gitFailureMessage('rev-parse', outcome) ?? 'git failed' };
112
- }
113
- if (outcome.exitCode !== 0) {
114
- return { success: false, message: `Unknown revision: ${revision}` };
115
- }
116
- }
117
-
118
- const outcome = await port.run(gitDiffArgv(parsed.args), { cwd });
119
- const failure = gitFailureMessage('diff', outcome);
120
- if (failure !== undefined || outcome.kind !== 'exited') {
121
- return { success: false, message: failure ?? 'git diff failed' };
122
- }
123
- const diff = outcome.stdout.replace(/\n$/, '');
124
- return {
125
- success: true,
126
- message: diff.length > 0 ? diff : 'No differences.',
127
- data: { git: 'diff', target: parsed.args.target, paths: parsed.args.paths },
128
- };
129
- }