@soleri/cli 9.7.1 → 9.8.0

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/dist/commands/add-domain.js +1 -0
  2. package/dist/commands/add-domain.js.map +1 -1
  3. package/dist/commands/add-pack.js +7 -147
  4. package/dist/commands/add-pack.js.map +1 -1
  5. package/dist/commands/agent.js +130 -0
  6. package/dist/commands/agent.js.map +1 -1
  7. package/dist/commands/create.js +78 -2
  8. package/dist/commands/create.js.map +1 -1
  9. package/dist/commands/doctor.js +2 -0
  10. package/dist/commands/doctor.js.map +1 -1
  11. package/dist/commands/extend.js +17 -0
  12. package/dist/commands/extend.js.map +1 -1
  13. package/dist/commands/install-knowledge.js +1 -0
  14. package/dist/commands/install-knowledge.js.map +1 -1
  15. package/dist/commands/test.js +140 -1
  16. package/dist/commands/test.js.map +1 -1
  17. package/dist/hook-packs/flock-guard/manifest.json +2 -1
  18. package/dist/hook-packs/installer.js +12 -5
  19. package/dist/hook-packs/installer.js.map +1 -1
  20. package/dist/hook-packs/installer.ts +26 -7
  21. package/dist/hook-packs/marketing-research/manifest.json +2 -1
  22. package/dist/hook-packs/registry.d.ts +2 -0
  23. package/dist/hook-packs/registry.js.map +1 -1
  24. package/dist/hook-packs/registry.ts +2 -0
  25. package/dist/prompts/create-wizard.d.ts +16 -2
  26. package/dist/prompts/create-wizard.js +84 -11
  27. package/dist/prompts/create-wizard.js.map +1 -1
  28. package/dist/utils/checks.d.ts +8 -5
  29. package/dist/utils/checks.js +105 -10
  30. package/dist/utils/checks.js.map +1 -1
  31. package/dist/utils/format-paths.d.ts +14 -0
  32. package/dist/utils/format-paths.js +27 -0
  33. package/dist/utils/format-paths.js.map +1 -0
  34. package/dist/utils/git.d.ts +29 -0
  35. package/dist/utils/git.js +88 -0
  36. package/dist/utils/git.js.map +1 -0
  37. package/dist/utils/logger.d.ts +1 -0
  38. package/dist/utils/logger.js +4 -0
  39. package/dist/utils/logger.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/__tests__/create-wizard-git.test.ts +208 -0
  42. package/src/__tests__/git-utils.test.ts +268 -0
  43. package/src/__tests__/hook-packs.test.ts +5 -1
  44. package/src/__tests__/scaffold-git-e2e.test.ts +105 -0
  45. package/src/commands/add-domain.ts +1 -0
  46. package/src/commands/add-pack.ts +10 -163
  47. package/src/commands/agent.ts +161 -0
  48. package/src/commands/create.ts +89 -3
  49. package/src/commands/doctor.ts +1 -0
  50. package/src/commands/extend.ts +20 -1
  51. package/src/commands/install-knowledge.ts +1 -0
  52. package/src/commands/test.ts +141 -2
  53. package/src/hook-packs/flock-guard/manifest.json +2 -1
  54. package/src/hook-packs/installer.ts +26 -7
  55. package/src/hook-packs/marketing-research/manifest.json +2 -1
  56. package/src/hook-packs/registry.ts +2 -0
  57. package/src/prompts/create-wizard.ts +109 -14
  58. package/src/utils/checks.ts +122 -13
  59. package/src/utils/format-paths.ts +41 -0
  60. package/src/utils/git.ts +118 -0
  61. package/src/utils/logger.ts +5 -0
@@ -0,0 +1,208 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ /**
4
+ * Mock @clack/prompts — every prompt function the wizard uses.
5
+ */
6
+ vi.mock('@clack/prompts', () => ({
7
+ confirm: vi.fn(),
8
+ select: vi.fn(),
9
+ text: vi.fn(),
10
+ intro: vi.fn(),
11
+ note: vi.fn(),
12
+ isCancel: vi.fn().mockReturnValue(false),
13
+ }));
14
+
15
+ /**
16
+ * Mock the git utility so we control gh availability.
17
+ */
18
+ vi.mock('../utils/git.js', () => ({
19
+ isGhInstalled: vi.fn().mockReturnValue(true),
20
+ }));
21
+
22
+ import * as p from '@clack/prompts';
23
+ import { isGhInstalled } from '../utils/git.js';
24
+ import { runCreateWizard } from '../prompts/create-wizard.js';
25
+ import type { CreateWizardResult, WizardGitConfig } from '../prompts/create-wizard.js';
26
+
27
+ const mockText = p.text as unknown as ReturnType<typeof vi.fn>;
28
+ const mockSelect = p.select as unknown as ReturnType<typeof vi.fn>;
29
+ const mockConfirm = p.confirm as unknown as ReturnType<typeof vi.fn>;
30
+ const mockIsCancel = p.isCancel as unknown as ReturnType<typeof vi.fn>;
31
+ const mockGhInstalled = isGhInstalled as unknown as ReturnType<typeof vi.fn>;
32
+
33
+ /**
34
+ * Helper: set up the standard wizard prompts that precede git questions.
35
+ * Returns after the "Create this agent?" confirm (step 4).
36
+ *
37
+ * Call order:
38
+ * 1. text — agent name
39
+ * 2. select — persona choice
40
+ * 3. note — summary (no return value needed)
41
+ * 4. confirm — create confirmation
42
+ */
43
+ function mockBaseWizard() {
44
+ mockText.mockResolvedValueOnce('TestAgent'); // name
45
+ mockSelect.mockResolvedValueOnce('default'); // persona
46
+ mockConfirm.mockResolvedValueOnce(true); // create confirm
47
+ }
48
+
49
+ describe('create-wizard git prompts', () => {
50
+ beforeEach(() => {
51
+ vi.clearAllMocks();
52
+ mockIsCancel.mockReturnValue(false);
53
+ mockGhInstalled.mockReturnValue(true);
54
+ });
55
+
56
+ // ── 1. Git init defaults to true ──────────────────────────────
57
+ it('should default git init to true when user accepts all defaults', async () => {
58
+ mockBaseWizard();
59
+ mockConfirm.mockResolvedValueOnce(true); // git init = yes
60
+ mockConfirm.mockResolvedValueOnce(false); // push to remote = no
61
+
62
+ const result = (await runCreateWizard()) as CreateWizardResult;
63
+
64
+ expect(result).not.toBeNull();
65
+ expect(result.git.init).toBe(true);
66
+ expect(result.git.remote).toBeUndefined();
67
+ });
68
+
69
+ // ── 2. Git init can be declined ───────────────────────────────
70
+ it('should set git.init to false when user declines', async () => {
71
+ mockBaseWizard();
72
+ mockConfirm.mockResolvedValueOnce(false); // git init = no
73
+
74
+ const result = (await runCreateWizard()) as CreateWizardResult;
75
+
76
+ expect(result).not.toBeNull();
77
+ expect(result.git.init).toBe(false);
78
+ expect(result.git.remote).toBeUndefined();
79
+ // No push prompt should have been called — only 2 confirms total
80
+ // (create + git init)
81
+ expect(mockConfirm).toHaveBeenCalledTimes(2);
82
+ });
83
+
84
+ // ── 3. Remote prompts only when init=true, no push ────────────
85
+ it('should leave remote undefined when init=true but push=false', async () => {
86
+ mockBaseWizard();
87
+ mockConfirm.mockResolvedValueOnce(true); // git init
88
+ mockConfirm.mockResolvedValueOnce(false); // push to remote = no
89
+
90
+ const result = (await runCreateWizard()) as CreateWizardResult;
91
+
92
+ expect(result).not.toBeNull();
93
+ expect(result.git.init).toBe(true);
94
+ expect(result.git.remote).toBeUndefined();
95
+ });
96
+
97
+ // ── 4. gh path: GitHub repo creation ──────────────────────────
98
+ it('should configure gh remote when user selects gh path', async () => {
99
+ mockBaseWizard();
100
+ mockConfirm.mockResolvedValueOnce(true); // git init
101
+ mockConfirm.mockResolvedValueOnce(true); // push to remote
102
+ mockSelect.mockResolvedValueOnce('gh'); // remote choice
103
+ mockSelect.mockResolvedValueOnce('public'); // visibility
104
+
105
+ const result = (await runCreateWizard()) as CreateWizardResult;
106
+
107
+ expect(result).not.toBeNull();
108
+ expect(result.git.remote).toEqual({
109
+ type: 'gh',
110
+ visibility: 'public',
111
+ });
112
+ });
113
+
114
+ // ── 5. gh path: private by default ────────────────────────────
115
+ it('should default visibility to private when selecting gh', async () => {
116
+ mockBaseWizard();
117
+ mockConfirm.mockResolvedValueOnce(true); // git init
118
+ mockConfirm.mockResolvedValueOnce(true); // push to remote
119
+ mockSelect.mockResolvedValueOnce('gh'); // remote choice
120
+ mockSelect.mockResolvedValueOnce('private'); // visibility = private
121
+
122
+ const result = (await runCreateWizard()) as CreateWizardResult;
123
+
124
+ expect(result).not.toBeNull();
125
+ expect(result.git.remote!.type).toBe('gh');
126
+ expect(result.git.remote!.visibility).toBe('private');
127
+ });
128
+
129
+ // ── 6. Manual path: URL input ─────────────────────────────────
130
+ it('should configure manual remote with URL when user selects manual', async () => {
131
+ mockBaseWizard();
132
+ mockConfirm.mockResolvedValueOnce(true); // git init
133
+ mockConfirm.mockResolvedValueOnce(true); // push to remote
134
+ mockSelect.mockResolvedValueOnce('manual'); // remote choice
135
+ mockText.mockResolvedValueOnce('https://github.com/test/repo.git'); // URL
136
+
137
+ const result = (await runCreateWizard()) as CreateWizardResult;
138
+
139
+ expect(result).not.toBeNull();
140
+ expect(result.git.remote).toEqual({
141
+ type: 'manual',
142
+ url: 'https://github.com/test/repo.git',
143
+ });
144
+ });
145
+
146
+ // ── 7. Manual path forced when gh not available ───────────────
147
+ it('should skip gh/manual select and go straight to URL when gh is not installed', async () => {
148
+ mockGhInstalled.mockReturnValue(false);
149
+
150
+ mockBaseWizard();
151
+ mockConfirm.mockResolvedValueOnce(true); // git init
152
+ mockConfirm.mockResolvedValueOnce(true); // push to remote
153
+ // No select for gh/manual — goes straight to text for URL
154
+ mockText.mockResolvedValueOnce('https://gitlab.com/test/repo.git');
155
+
156
+ const result = (await runCreateWizard()) as CreateWizardResult;
157
+
158
+ expect(result).not.toBeNull();
159
+ expect(result.git.remote).toEqual({
160
+ type: 'manual',
161
+ url: 'https://gitlab.com/test/repo.git',
162
+ });
163
+ // select should only be called once (persona), not twice
164
+ expect(mockSelect).toHaveBeenCalledTimes(1);
165
+ });
166
+
167
+ // ── 8. Cancellation at git init prompt ────────────────────────
168
+ it('should return null when user cancels at git init prompt', async () => {
169
+ mockBaseWizard();
170
+ // The third confirm call (git init) returns a cancel symbol
171
+ const cancelSymbol = Symbol('cancel');
172
+ mockConfirm.mockResolvedValueOnce(cancelSymbol);
173
+ mockIsCancel.mockImplementation((val) => val === cancelSymbol);
174
+
175
+ const result = await runCreateWizard();
176
+
177
+ expect(result).toBeNull();
178
+ });
179
+
180
+ // ── 9. Cancellation at push prompt ────────────────────────────
181
+ it('should return null when user cancels at push prompt', async () => {
182
+ mockBaseWizard();
183
+ mockConfirm.mockResolvedValueOnce(true); // git init = yes
184
+
185
+ const cancelSymbol = Symbol('cancel');
186
+ mockConfirm.mockResolvedValueOnce(cancelSymbol); // push = cancel
187
+ mockIsCancel.mockImplementation((val) => val === cancelSymbol);
188
+
189
+ const result = await runCreateWizard();
190
+
191
+ expect(result).toBeNull();
192
+ });
193
+
194
+ // ── 10. Cancellation at remote choice ─────────────────────────
195
+ it('should return null when user cancels at remote choice prompt', async () => {
196
+ mockBaseWizard();
197
+ mockConfirm.mockResolvedValueOnce(true); // git init
198
+ mockConfirm.mockResolvedValueOnce(true); // push to remote
199
+
200
+ const cancelSymbol = Symbol('cancel');
201
+ mockSelect.mockResolvedValueOnce(cancelSymbol); // remote choice = cancel
202
+ mockIsCancel.mockImplementation((val) => val === cancelSymbol);
203
+
204
+ const result = await runCreateWizard();
205
+
206
+ expect(result).toBeNull();
207
+ });
208
+ });
@@ -0,0 +1,268 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Mock node:child_process before importing the module under test
4
+ vi.mock('node:child_process', () => ({
5
+ execFile: vi.fn(),
6
+ }));
7
+
8
+ import { execFile } from 'node:child_process';
9
+ import {
10
+ isGitInstalled,
11
+ isGhInstalled,
12
+ gitInit,
13
+ gitInitialCommit,
14
+ gitAddRemote,
15
+ gitPush,
16
+ ghCreateRepo,
17
+ } from '../utils/git.js';
18
+
19
+ const mockExecFile = vi.mocked(execFile);
20
+
21
+ /** Helper: make execFile call its callback with success (stdout). */
22
+ function mockSuccess(stdout = '') {
23
+ mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => {
24
+ (callback as Function)(null, stdout, '');
25
+ return undefined as any;
26
+ });
27
+ }
28
+
29
+ /** Helper: make execFile call its callback with an error. */
30
+ function mockFailure(message: string, stderr = '') {
31
+ mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => {
32
+ const err = new Error(message);
33
+ (callback as Function)(err, '', stderr);
34
+ return undefined as any;
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Helper: make execFile succeed N times then fail.
40
+ * Useful for testing gitInitialCommit where `git add` must succeed before `git commit` fails.
41
+ */
42
+ function mockSequence(calls: Array<{ stdout?: string; error?: string; stderr?: string }>) {
43
+ let callIndex = 0;
44
+ mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => {
45
+ const spec = calls[callIndex] ?? calls[calls.length - 1];
46
+ callIndex++;
47
+ if (spec.error) {
48
+ const err = new Error(spec.error);
49
+ (callback as Function)(err, '', spec.stderr ?? spec.error);
50
+ } else {
51
+ (callback as Function)(null, spec.stdout ?? '', '');
52
+ }
53
+ return undefined as any;
54
+ });
55
+ }
56
+
57
+ describe('git utilities', () => {
58
+ beforeEach(() => {
59
+ vi.resetAllMocks();
60
+ });
61
+
62
+ // ── isGitInstalled ──────────────────────────────────────────────
63
+ describe('isGitInstalled', () => {
64
+ it('returns true when git binary is found', async () => {
65
+ mockSuccess('/usr/bin/git');
66
+ const result = await isGitInstalled();
67
+ expect(result).toBe(true);
68
+ expect(mockExecFile).toHaveBeenCalledWith(
69
+ 'which',
70
+ ['git'],
71
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
72
+ expect.any(Function),
73
+ );
74
+ });
75
+
76
+ it('returns false when git binary is not found', async () => {
77
+ mockFailure('not found');
78
+ const result = await isGitInstalled();
79
+ expect(result).toBe(false);
80
+ });
81
+ });
82
+
83
+ // ── isGhInstalled ──────────────────────────────────────────────
84
+ describe('isGhInstalled', () => {
85
+ it('returns true when gh binary is found', async () => {
86
+ mockSuccess('/usr/bin/gh');
87
+ const result = await isGhInstalled();
88
+ expect(result).toBe(true);
89
+ expect(mockExecFile).toHaveBeenCalledWith(
90
+ 'which',
91
+ ['gh'],
92
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
93
+ expect.any(Function),
94
+ );
95
+ });
96
+
97
+ it('returns false when gh binary is not found', async () => {
98
+ mockFailure('not found');
99
+ const result = await isGhInstalled();
100
+ expect(result).toBe(false);
101
+ });
102
+ });
103
+
104
+ // ── gitInit ─────────────────────────────────────────────────────
105
+ describe('gitInit', () => {
106
+ it('returns { ok: true } on success', async () => {
107
+ mockSuccess('Initialized empty Git repository');
108
+ const result = await gitInit('/tmp/my-project');
109
+ expect(result).toEqual({ ok: true });
110
+ });
111
+
112
+ it('returns { ok: false, error } on failure', async () => {
113
+ mockFailure('fatal: not a git repository', 'fatal: not a git repository');
114
+ const result = await gitInit('/tmp/my-project');
115
+ expect(result.ok).toBe(false);
116
+ expect(result.error).toBeDefined();
117
+ });
118
+
119
+ it('passes correct args with cwd', async () => {
120
+ mockSuccess();
121
+ await gitInit('/tmp/my-project');
122
+ expect(mockExecFile).toHaveBeenCalledWith(
123
+ 'git',
124
+ ['init'],
125
+ expect.objectContaining({ cwd: '/tmp/my-project' }),
126
+ expect.any(Function),
127
+ );
128
+ });
129
+ });
130
+
131
+ // ── gitInitialCommit ────────────────────────────────────────────
132
+ describe('gitInitialCommit', () => {
133
+ it('returns { ok: true } when both add and commit succeed', async () => {
134
+ mockSequence([{ stdout: '' }, { stdout: '' }]);
135
+ const result = await gitInitialCommit('/tmp/proj', 'Initial commit');
136
+ expect(result).toEqual({ ok: true });
137
+ // Two calls: git add . and git commit -m ...
138
+ expect(mockExecFile).toHaveBeenCalledTimes(2);
139
+ });
140
+
141
+ it('returns { ok: false } when git add fails (does not attempt commit)', async () => {
142
+ mockSequence([{ error: 'add failed', stderr: 'add failed' }]);
143
+ const result = await gitInitialCommit('/tmp/proj', 'Initial commit');
144
+ expect(result.ok).toBe(false);
145
+ expect(result.error).toContain('add failed');
146
+ // Only one call — git add; commit should not be attempted
147
+ expect(mockExecFile).toHaveBeenCalledTimes(1);
148
+ });
149
+
150
+ it('returns { ok: false } when add succeeds but commit fails', async () => {
151
+ mockSequence([{ stdout: '' }, { error: 'nothing to commit', stderr: 'nothing to commit' }]);
152
+ const result = await gitInitialCommit('/tmp/proj', 'Initial commit');
153
+ expect(result.ok).toBe(false);
154
+ expect(result.error).toContain('nothing to commit');
155
+ expect(mockExecFile).toHaveBeenCalledTimes(2);
156
+ });
157
+
158
+ it('passes the correct commit message', async () => {
159
+ mockSequence([{ stdout: '' }, { stdout: '' }]);
160
+ await gitInitialCommit('/tmp/proj', 'feat: initial scaffold');
161
+ // Second call should be git commit -m <message>
162
+ const commitCall = mockExecFile.mock.calls[1];
163
+ expect(commitCall[0]).toBe('git');
164
+ expect(commitCall[1]).toEqual(['commit', '-m', 'feat: initial scaffold']);
165
+ });
166
+ });
167
+
168
+ // ── gitAddRemote ────────────────────────────────────────────────
169
+ describe('gitAddRemote', () => {
170
+ it('returns { ok: true } on success', async () => {
171
+ mockSuccess();
172
+ const result = await gitAddRemote('/tmp/proj', 'https://github.com/user/repo.git');
173
+ expect(result).toEqual({ ok: true });
174
+ });
175
+
176
+ it('passes correct args', async () => {
177
+ mockSuccess();
178
+ await gitAddRemote('/tmp/proj', 'https://github.com/user/repo.git');
179
+ expect(mockExecFile).toHaveBeenCalledWith(
180
+ 'git',
181
+ ['remote', 'add', 'origin', 'https://github.com/user/repo.git'],
182
+ expect.objectContaining({ cwd: '/tmp/proj' }),
183
+ expect.any(Function),
184
+ );
185
+ });
186
+
187
+ it('returns { ok: false } on failure', async () => {
188
+ mockFailure('remote origin already exists', 'remote origin already exists');
189
+ const result = await gitAddRemote('/tmp/proj', 'https://github.com/user/repo.git');
190
+ expect(result.ok).toBe(false);
191
+ expect(result.error).toBeDefined();
192
+ });
193
+ });
194
+
195
+ // ── gitPush ─────────────────────────────────────────────────────
196
+ describe('gitPush', () => {
197
+ it('returns { ok: true } on success', async () => {
198
+ mockSuccess();
199
+ const result = await gitPush('/tmp/proj');
200
+ expect(result).toEqual({ ok: true });
201
+ });
202
+
203
+ it('returns { ok: false } on network error', async () => {
204
+ mockFailure('Could not resolve host', 'Could not resolve host');
205
+ const result = await gitPush('/tmp/proj');
206
+ expect(result.ok).toBe(false);
207
+ expect(result.error).toContain('Could not resolve host');
208
+ });
209
+
210
+ it('uses NETWORK_TIMEOUT (60s) via AbortSignal', async () => {
211
+ mockSuccess();
212
+ await gitPush('/tmp/proj');
213
+ const callOpts = mockExecFile.mock.calls[0][2] as { signal: AbortSignal };
214
+ // AbortSignal.timeout(60000) — verify it is an AbortSignal (we can't read the timeout
215
+ // value directly, but we can verify it's present and is an AbortSignal)
216
+ expect(callOpts.signal).toBeInstanceOf(AbortSignal);
217
+ });
218
+ });
219
+
220
+ // ── ghCreateRepo ────────────────────────────────────────────────
221
+ describe('ghCreateRepo', () => {
222
+ it('returns { ok: true, url } on success', async () => {
223
+ mockSuccess('https://github.com/user/my-repo\n');
224
+ const result = await ghCreateRepo('my-repo', { visibility: 'public', dir: '/tmp/proj' });
225
+ expect(result.ok).toBe(true);
226
+ expect(result.url).toBe('https://github.com/user/my-repo');
227
+ });
228
+
229
+ it('returns { ok: false } on failure', async () => {
230
+ mockFailure('authentication required', 'authentication required');
231
+ const result = await ghCreateRepo('my-repo', { visibility: 'public', dir: '/tmp/proj' });
232
+ expect(result.ok).toBe(false);
233
+ expect(result.error).toBeDefined();
234
+ });
235
+
236
+ it('passes --public flag for public visibility', async () => {
237
+ mockSuccess('https://github.com/user/my-repo');
238
+ await ghCreateRepo('my-repo', { visibility: 'public', dir: '/tmp/proj' });
239
+ const args = mockExecFile.mock.calls[0][1] as string[];
240
+ expect(args).toContain('--public');
241
+ expect(args).not.toContain('--private');
242
+ });
243
+
244
+ it('passes --private flag for private visibility', async () => {
245
+ mockSuccess('https://github.com/user/my-repo');
246
+ await ghCreateRepo('my-repo', { visibility: 'private', dir: '/tmp/proj' });
247
+ const args = mockExecFile.mock.calls[0][1] as string[];
248
+ expect(args).toContain('--private');
249
+ expect(args).not.toContain('--public');
250
+ });
251
+
252
+ it('includes --source, --remote, and --push flags', async () => {
253
+ mockSuccess('https://github.com/user/my-repo');
254
+ await ghCreateRepo('my-repo', { visibility: 'public', dir: '/tmp/proj' });
255
+ const args = mockExecFile.mock.calls[0][1] as string[];
256
+ expect(args).toContain('--source=/tmp/proj');
257
+ expect(args).toContain('--remote=origin');
258
+ expect(args).toContain('--push');
259
+ });
260
+
261
+ it('returns undefined url when stdout is empty', async () => {
262
+ mockSuccess('');
263
+ const result = await ghCreateRepo('my-repo', { visibility: 'public', dir: '/tmp/proj' });
264
+ expect(result.ok).toBe(true);
265
+ expect(result.url).toBeUndefined();
266
+ });
267
+ });
268
+ });
@@ -176,7 +176,11 @@ describe('hook-packs', () => {
176
176
  const settings = JSON.parse(readFileSync(join(claudeDir, 'settings.json'), 'utf-8'));
177
177
  expect(settings.hooks).toBeDefined();
178
178
  expect(settings.hooks.PreToolUse).toHaveLength(1);
179
- expect(settings.hooks.PreToolUse[0].command).toBe('sh ~/.claude/hooks/anti-deletion.sh');
179
+ expect(settings.hooks.PreToolUse[0].matcher).toBe('Bash');
180
+ expect(settings.hooks.PreToolUse[0].hooks).toHaveLength(1);
181
+ expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe(
182
+ 'sh ~/.claude/hooks/anti-deletion.sh',
183
+ );
180
184
  expect(settings.hooks.PreToolUse[0]._soleriPack).toBe('safety');
181
185
  });
182
186
 
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { scaffoldFileTree } from '@soleri/forge/lib';
7
+ import { gitInit, gitInitialCommit } from '../utils/git.js';
8
+ import type { AgentYamlInput } from '@soleri/forge/lib';
9
+
10
+ // ─── Helpers ─────────────────────────────────────────────────────────
11
+
12
+ const MINIMAL_AGENT: AgentYamlInput = {
13
+ id: 'test-agent',
14
+ name: 'Test Agent',
15
+ role: 'Testing assistant',
16
+ description: 'A minimal agent used for scaffold + git E2E tests',
17
+ };
18
+
19
+ function gitCommand(dir: string, ...args: string[]): string {
20
+ return execFileSync('git', args, {
21
+ cwd: dir,
22
+ stdio: 'pipe',
23
+ encoding: 'utf-8',
24
+ }).trim();
25
+ }
26
+
27
+ // ─── Tests ───────────────────────────────────────────────────────────
28
+
29
+ describe('scaffold + git init (E2E)', () => {
30
+ const tempDirs: string[] = [];
31
+
32
+ function makeTempDir(): string {
33
+ const dir = mkdtempSync(join(tmpdir(), 'soleri-git-e2e-'));
34
+ tempDirs.push(dir);
35
+ return dir;
36
+ }
37
+
38
+ afterEach(() => {
39
+ for (const dir of tempDirs) {
40
+ rmSync(dir, { recursive: true, force: true });
41
+ }
42
+ tempDirs.length = 0;
43
+ });
44
+
45
+ it('scaffold with git init produces a valid git repo', async () => {
46
+ const outputDir = makeTempDir();
47
+ const result = scaffoldFileTree(MINIMAL_AGENT, outputDir);
48
+ expect(result.success).toBe(true);
49
+
50
+ const agentDir = result.agentDir;
51
+
52
+ // Initialize git and create initial commit
53
+ const initResult = await gitInit(agentDir);
54
+ expect(initResult.ok).toBe(true);
55
+
56
+ const commitResult = await gitInitialCommit(agentDir, 'feat: scaffold agent "test-agent"');
57
+ expect(commitResult.ok).toBe(true);
58
+
59
+ // .git directory exists
60
+ expect(existsSync(join(agentDir, '.git'))).toBe(true);
61
+
62
+ // Exactly 1 commit
63
+ const log = gitCommand(agentDir, 'log', '--oneline');
64
+ const commits = log.split('\n').filter(Boolean);
65
+ expect(commits).toHaveLength(1);
66
+
67
+ // Commit message contains expected text
68
+ expect(commits[0]).toContain('feat: scaffold agent');
69
+
70
+ // Working tree is clean — no untracked files
71
+ const status = gitCommand(agentDir, 'status', '--porcelain');
72
+ expect(status).toBe('');
73
+ });
74
+
75
+ it('.gitignore exclusions are respected', async () => {
76
+ const outputDir = makeTempDir();
77
+ const result = scaffoldFileTree(MINIMAL_AGENT, outputDir);
78
+ expect(result.success).toBe(true);
79
+
80
+ const agentDir = result.agentDir;
81
+
82
+ await gitInit(agentDir);
83
+ await gitInitialCommit(agentDir, 'feat: scaffold agent "test-agent"');
84
+
85
+ const trackedFiles = gitCommand(agentDir, 'ls-files');
86
+
87
+ // Auto-generated files must NOT be tracked
88
+ expect(trackedFiles).not.toContain('CLAUDE.md');
89
+ expect(trackedFiles).not.toContain('AGENTS.md');
90
+ expect(trackedFiles).not.toContain('instructions/_engine.md');
91
+
92
+ // Important source-of-truth files MUST be tracked
93
+ expect(trackedFiles).toContain('agent.yaml');
94
+ expect(trackedFiles).toContain('.gitignore');
95
+ });
96
+
97
+ it('scaffold without git does not create a .git directory', () => {
98
+ const outputDir = makeTempDir();
99
+ const result = scaffoldFileTree(MINIMAL_AGENT, outputDir);
100
+ expect(result.success).toBe(true);
101
+
102
+ // No git init called — .git must not exist
103
+ expect(existsSync(join(result.agentDir, '.git'))).toBe(false);
104
+ });
105
+ });
@@ -24,6 +24,7 @@ export function registerAddDomain(program: Command): void {
24
24
  agentPath: ctx.agentPath,
25
25
  domain,
26
26
  noBuild: !opts.build,
27
+ format: ctx.format,
27
28
  });
28
29
 
29
30
  s.stop(result.success ? result.summary : 'Failed');