@specmarket/cli 0.0.4 → 0.0.6

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 (39) hide show
  1. package/README.md +1 -1
  2. package/dist/{chunk-MS2DYACY.js → chunk-OTXWWFAO.js} +42 -3
  3. package/dist/chunk-OTXWWFAO.js.map +1 -0
  4. package/dist/{config-R5KWZSJP.js → config-5JMI3YAR.js} +2 -2
  5. package/dist/index.js +1945 -252
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/commands/comment.test.ts +211 -0
  9. package/src/commands/comment.ts +176 -0
  10. package/src/commands/fork.test.ts +163 -0
  11. package/src/commands/info.test.ts +192 -0
  12. package/src/commands/info.ts +66 -2
  13. package/src/commands/init.test.ts +245 -0
  14. package/src/commands/init.ts +359 -25
  15. package/src/commands/issues.test.ts +382 -0
  16. package/src/commands/issues.ts +436 -0
  17. package/src/commands/login.test.ts +99 -0
  18. package/src/commands/login.ts +2 -6
  19. package/src/commands/logout.test.ts +54 -0
  20. package/src/commands/publish.test.ts +159 -0
  21. package/src/commands/publish.ts +1 -0
  22. package/src/commands/report.test.ts +181 -0
  23. package/src/commands/run.test.ts +419 -0
  24. package/src/commands/run.ts +71 -3
  25. package/src/commands/search.test.ts +147 -0
  26. package/src/commands/validate.test.ts +206 -2
  27. package/src/commands/validate.ts +315 -192
  28. package/src/commands/whoami.test.ts +106 -0
  29. package/src/index.ts +6 -0
  30. package/src/lib/convex-client.ts +6 -2
  31. package/src/lib/format-detection.test.ts +223 -0
  32. package/src/lib/format-detection.ts +172 -0
  33. package/src/lib/meta-instructions.test.ts +340 -0
  34. package/src/lib/meta-instructions.ts +562 -0
  35. package/src/lib/ralph-loop.test.ts +404 -0
  36. package/src/lib/ralph-loop.ts +501 -95
  37. package/src/lib/telemetry.ts +7 -1
  38. package/dist/chunk-MS2DYACY.js.map +0 -1
  39. /package/dist/{config-R5KWZSJP.js.map → config-5JMI3YAR.js.map} +0 -0
@@ -0,0 +1,192 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // --- Hoisted mocks ---
4
+
5
+ const { mockQuery, mockAction, mockClient, mockSpinner } = vi.hoisted(() => {
6
+ const mockQuery = vi.fn();
7
+ const mockAction = vi.fn();
8
+ const mockClient = { query: mockQuery, action: mockAction };
9
+ const mockSpinner = {
10
+ start: vi.fn().mockReturnThis(),
11
+ stop: vi.fn().mockReturnThis(),
12
+ succeed: vi.fn().mockReturnThis(),
13
+ fail: vi.fn().mockReturnThis(),
14
+ };
15
+ return { mockQuery, mockAction, mockClient, mockSpinner };
16
+ });
17
+
18
+ vi.mock('../lib/convex-client.js', () => ({
19
+ getConvexClient: vi.fn().mockResolvedValue(mockClient),
20
+ }));
21
+
22
+ vi.mock('ora', () => ({
23
+ default: vi.fn().mockReturnValue(mockSpinner),
24
+ }));
25
+
26
+ vi.mock('@specmarket/convex/api', () => ({
27
+ api: {
28
+ specs: {
29
+ get: 'specs.get',
30
+ getVersions: 'specs.getVersions',
31
+ },
32
+ runs: { getStats: 'runs.getStats' },
33
+ users: { getProfile: 'users.getProfile' },
34
+ issues: { list: 'issues.list' },
35
+ specMaintainers: { list: 'specMaintainers.list' },
36
+ comments: { list: 'comments.list' },
37
+ },
38
+ }));
39
+
40
+ const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {
41
+ throw new Error('process.exit called');
42
+ }) as any);
43
+
44
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
45
+ vi.spyOn(console, 'error').mockImplementation(() => {});
46
+
47
+ import { handleInfo } from './info.js';
48
+
49
+ // --- Test data ---
50
+
51
+ const MOCK_SPEC = {
52
+ _id: 'spec123',
53
+ scopedName: '@alice/todo-app',
54
+ displayName: 'Todo App',
55
+ description: 'A full-featured todo application',
56
+ slug: 'todo-app',
57
+ authorId: 'user1',
58
+ outputType: 'web-app',
59
+ primaryStack: 'nextjs-typescript',
60
+ currentVersion: '1.2.0',
61
+ runner: 'claude',
62
+ minModel: 'claude-opus-4-5',
63
+ status: 'published',
64
+ tags: ['productivity', 'todo'],
65
+ estimatedTokens: 50000,
66
+ estimatedCostUsd: 2.5,
67
+ estimatedTimeMinutes: 30,
68
+ totalRuns: 42,
69
+ successfulRuns: 36,
70
+ successRate: 0.857,
71
+ avgCostUsd: 2.1,
72
+ communityRating: 4.3,
73
+ ratingCount: 15,
74
+ forkCount: 3,
75
+ replacesSaas: 'Todoist',
76
+ replacesPricing: '$4/mo',
77
+ forkedFromId: null,
78
+ infrastructure: null,
79
+ };
80
+
81
+ // Return Promises from mockQuery since info.ts chains .catch() on query results
82
+ function setupQueryMock(overrides: Record<string, unknown> = {}) {
83
+ const defaults: Record<string, unknown> = {
84
+ 'specs.get': MOCK_SPEC,
85
+ 'runs.getStats': { totalRuns: 0, modelBreakdown: [], costDistribution: { min: 0, max: 0, p50: 0 } },
86
+ 'specs.getVersions': { page: [] },
87
+ 'issues.list': { page: [], isDone: true },
88
+ 'specMaintainers.list': [],
89
+ 'comments.list': { page: [], isDone: true },
90
+ 'users.getProfile': null,
91
+ };
92
+ const merged = { ...defaults, ...overrides };
93
+ mockQuery.mockImplementation((fn: string) => Promise.resolve(merged[fn] ?? null));
94
+ }
95
+
96
+ describe('handleInfo', () => {
97
+ beforeEach(() => {
98
+ vi.clearAllMocks();
99
+ mockExit.mockImplementation((() => {
100
+ throw new Error('process.exit called');
101
+ }) as any);
102
+ });
103
+
104
+ it('displays full spec information for a scoped name', async () => {
105
+ setupQueryMock({
106
+ 'users.getProfile': { username: 'alice', displayName: 'Alice', bio: 'Developer' },
107
+ });
108
+
109
+ await handleInfo('@alice/todo-app');
110
+
111
+ expect(mockQuery).toHaveBeenCalledWith('specs.get', {
112
+ scopedName: '@alice/todo-app',
113
+ });
114
+ expect(consoleSpy).toHaveBeenCalledWith(
115
+ expect.stringContaining('Todo App')
116
+ );
117
+ expect(consoleSpy).toHaveBeenCalledWith(
118
+ expect.stringContaining('web-app')
119
+ );
120
+ expect(consoleSpy).toHaveBeenCalledWith(
121
+ expect.stringContaining('85.7%')
122
+ );
123
+ expect(consoleSpy).toHaveBeenCalledWith(
124
+ expect.stringContaining('Todoist')
125
+ );
126
+ });
127
+
128
+ it('exits with error when spec not found', async () => {
129
+ setupQueryMock({ 'specs.get': null });
130
+
131
+ await expect(handleInfo('@alice/nonexistent')).rejects.toThrow(
132
+ 'process.exit called'
133
+ );
134
+
135
+ expect(consoleSpy).toHaveBeenCalledWith(
136
+ expect.stringContaining('Spec not found')
137
+ );
138
+ });
139
+
140
+ it('shows version history when versions exist', async () => {
141
+ setupQueryMock({
142
+ 'specs.getVersions': {
143
+ page: [
144
+ { version: '1.2.0', publishedAt: Date.now(), changelog: 'Added dark mode' },
145
+ { version: '1.1.0', publishedAt: Date.now() - 86400_000, changelog: 'Bug fixes' },
146
+ ],
147
+ },
148
+ });
149
+
150
+ await handleInfo('@alice/todo-app');
151
+
152
+ expect(consoleSpy).toHaveBeenCalledWith(
153
+ expect.stringContaining('Versions (2)')
154
+ );
155
+ expect(consoleSpy).toHaveBeenCalledWith(
156
+ expect.stringContaining('v1.2.0')
157
+ );
158
+ });
159
+
160
+ it('displays infrastructure details when present', async () => {
161
+ setupQueryMock({
162
+ 'specs.get': {
163
+ ...MOCK_SPEC,
164
+ infrastructure: {
165
+ monthlyCost: { freeTierUsd: 0, productionUsd: 25 },
166
+ setupTimeMinutes: 15,
167
+ services: [
168
+ {
169
+ category: 'database',
170
+ name: 'PostgreSQL',
171
+ purpose: 'Data store',
172
+ required: true,
173
+ defaultProvider: 'Neon',
174
+ providers: [{ name: 'Neon', freeTier: true }],
175
+ },
176
+ ],
177
+ userProvided: [],
178
+ deploymentTargets: [{ name: 'Vercel', notes: 'Recommended' }],
179
+ },
180
+ },
181
+ });
182
+
183
+ await handleInfo('@alice/todo-app');
184
+
185
+ expect(consoleSpy).toHaveBeenCalledWith(
186
+ expect.stringContaining('Infrastructure')
187
+ );
188
+ expect(consoleSpy).toHaveBeenCalledWith(
189
+ expect.stringContaining('PostgreSQL')
190
+ );
191
+ });
192
+ });
@@ -26,11 +26,56 @@ export async function handleInfo(specId: string): Promise<void> {
26
26
  // Determine if it's a scoped name or an ID
27
27
  const isScopedName = specId.startsWith('@') || specId.includes('/');
28
28
 
29
- const [spec, stats, versions] = await Promise.all([
29
+ const [spec, stats, versionsResult] = await Promise.all([
30
30
  client.query(api.specs.get, isScopedName ? { scopedName: specId } : { specId }),
31
31
  client.query(api.runs.getStats, { specId: specId as any }).catch(() => null),
32
- client.query(api.specs.getVersions, { specId: specId as any }).catch(() => []),
32
+ client.query(api.specs.getVersions, { specId: specId as any, paginationOpts: { numItems: 25, cursor: null } }).catch(() => ({ page: [] })),
33
33
  ]);
34
+ const versions = versionsResult.page;
35
+
36
+ // Fetch collaboration data (best-effort — don't fail if these aren't available)
37
+ let openIssueCount = 0;
38
+ let maintainers: Array<{ user: { username: string } | null }> = [];
39
+ let commentCount = 0;
40
+
41
+ if (spec) {
42
+ const [issuesResult, maintainersResult, commentsResult] = await Promise.all([
43
+ client
44
+ .query(api.issues.list, {
45
+ specId: spec._id,
46
+ status: 'open' as const,
47
+ paginationOpts: { numItems: 1, cursor: null },
48
+ })
49
+ .catch(() => null),
50
+ client
51
+ .query(api.specMaintainers.list, { specId: spec._id })
52
+ .catch(() => []),
53
+ client
54
+ .query(api.comments.list, {
55
+ targetType: 'spec' as const,
56
+ targetId: spec._id,
57
+ paginationOpts: { numItems: 1, cursor: null },
58
+ })
59
+ .catch(() => null),
60
+ ]);
61
+
62
+ // issues.list returns paginated results — use page length as approximation
63
+ // For accurate count we'd need a dedicated count query, but this shows "at least N"
64
+ if (issuesResult) {
65
+ openIssueCount = issuesResult.page.length;
66
+ if (!issuesResult.isDone && openIssueCount > 0) {
67
+ // There are more pages — indicate "N+"
68
+ openIssueCount = -1; // sentinel for "many"
69
+ }
70
+ }
71
+ maintainers = maintainersResult;
72
+ if (commentsResult) {
73
+ commentCount = commentsResult.page.length;
74
+ if (!commentsResult.isDone && commentCount > 0) {
75
+ commentCount = -1; // sentinel for "many"
76
+ }
77
+ }
78
+ }
34
79
 
35
80
  spinner.stop();
36
81
 
@@ -91,6 +136,25 @@ export async function handleInfo(specId: string): Promise<void> {
91
136
  console.log(chalk.gray(` (Forked from v${spec.forkedFromVersion})`));
92
137
  }
93
138
 
139
+ // Collaboration info
140
+ const issueDisplay =
141
+ openIssueCount === -1 ? 'many' : String(openIssueCount);
142
+ const commentDisplay =
143
+ commentCount === -1 ? 'many' : String(commentCount);
144
+ console.log(
145
+ ` Open Issues: ${issueDisplay}`
146
+ );
147
+ console.log(
148
+ ` Comments: ${commentDisplay}`
149
+ );
150
+ if (maintainers.length > 0) {
151
+ const names = maintainers
152
+ .filter((m) => m.user)
153
+ .map((m) => `@${m.user!.username}`)
154
+ .join(', ');
155
+ console.log(` Maintainers: ${names}`);
156
+ }
157
+
94
158
  if (author) {
95
159
  console.log('');
96
160
  console.log(chalk.bold('Creator:'));
@@ -0,0 +1,245 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { readFile, rm, access, writeFile, mkdir } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import { randomUUID } from 'crypto';
6
+
7
+ // --- Hoisted mocks ---
8
+
9
+ const { mockSpinner, mockPrompt } = vi.hoisted(() => {
10
+ const mockSpinner = {
11
+ start: vi.fn().mockReturnThis(),
12
+ stop: vi.fn().mockReturnThis(),
13
+ succeed: vi.fn().mockReturnThis(),
14
+ fail: vi.fn().mockReturnThis(),
15
+ warn: vi.fn().mockReturnThis(),
16
+ };
17
+ const mockPrompt = vi.fn();
18
+ return { mockSpinner, mockPrompt };
19
+ });
20
+
21
+ vi.mock('ora', () => ({
22
+ default: vi.fn().mockReturnValue(mockSpinner),
23
+ }));
24
+
25
+ vi.mock('inquirer', () => ({
26
+ default: { prompt: mockPrompt },
27
+ }));
28
+
29
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
30
+
31
+ import { handleInit } from './init.js';
32
+ import { SIDECAR_FILENAME } from '@specmarket/shared';
33
+
34
+ describe('handleInit', () => {
35
+ let tmpDir: string;
36
+
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+ tmpDir = join(tmpdir(), `init-test-${randomUUID()}`);
40
+ });
41
+
42
+ afterEach(async () => {
43
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
44
+ });
45
+
46
+ it('creates spec directory with all required files', async () => {
47
+ // Path provided + empty dir: first prompt "Create from scratch?", then full spec answers
48
+ mockPrompt
49
+ .mockResolvedValueOnce({ createNew: true })
50
+ .mockResolvedValueOnce({
51
+ name: 'test-spec',
52
+ displayName: 'Test Spec',
53
+ replacesSaas: '',
54
+ outputType: 'web-app',
55
+ primaryStack: 'nextjs-typescript',
56
+ });
57
+
58
+ await handleInit({ path: tmpDir });
59
+
60
+ // Verify all required files were created (specmarket.yaml is required for validate/publish)
61
+ await expect(access(join(tmpDir, SIDECAR_FILENAME))).resolves.toBeUndefined();
62
+ await expect(access(join(tmpDir, 'spec.yaml'))).resolves.toBeUndefined();
63
+ await expect(access(join(tmpDir, 'PROMPT.md'))).resolves.toBeUndefined();
64
+ await expect(access(join(tmpDir, 'SPEC.md'))).resolves.toBeUndefined();
65
+ await expect(access(join(tmpDir, 'SUCCESS_CRITERIA.md'))).resolves.toBeUndefined();
66
+ await expect(access(join(tmpDir, 'stdlib', 'STACK.md'))).resolves.toBeUndefined();
67
+ await expect(access(join(tmpDir, 'TASKS.md'))).resolves.toBeUndefined();
68
+
69
+ expect(mockSpinner.succeed).toHaveBeenCalledWith(
70
+ expect.stringContaining('Spec created')
71
+ );
72
+ });
73
+
74
+ it('writes correct spec.yaml content based on prompts', async () => {
75
+ mockPrompt
76
+ .mockResolvedValueOnce({ createNew: true })
77
+ .mockResolvedValueOnce({
78
+ name: 'my-project',
79
+ displayName: 'My Project',
80
+ replacesSaas: 'Notion',
81
+ outputType: 'web-app',
82
+ primaryStack: 'nextjs-typescript',
83
+ });
84
+
85
+ await handleInit({ path: tmpDir });
86
+
87
+ const specYaml = await readFile(join(tmpDir, 'spec.yaml'), 'utf-8');
88
+ expect(specYaml).toContain('name: my-project');
89
+ expect(specYaml).toContain('display_name: "My Project"');
90
+ expect(specYaml).toContain('replaces_saas: "Notion"');
91
+ expect(specYaml).toContain('output_type: web-app');
92
+ expect(specYaml).toContain('primary_stack: nextjs-typescript');
93
+ });
94
+
95
+ it('shows next-steps instructions after creation', async () => {
96
+ mockPrompt
97
+ .mockResolvedValueOnce({ createNew: true })
98
+ .mockResolvedValueOnce({
99
+ name: 'test-spec',
100
+ displayName: 'Test Spec',
101
+ replacesSaas: '',
102
+ outputType: 'cli-tool',
103
+ primaryStack: 'go',
104
+ });
105
+
106
+ await handleInit({ path: tmpDir });
107
+
108
+ expect(consoleSpy).toHaveBeenCalledWith(
109
+ expect.stringContaining('Next steps')
110
+ );
111
+ expect(consoleSpy).toHaveBeenCalledWith(
112
+ expect.stringContaining('specmarket validate')
113
+ );
114
+ });
115
+
116
+ it('adds only specmarket.yaml when -p points at existing Spec Kit dir (no overwrites)', async () => {
117
+ await mkdir(tmpDir, { recursive: true });
118
+ await writeFile(join(tmpDir, 'spec.md'), '# My Spec Kit spec\nDo not overwrite.');
119
+ await writeFile(join(tmpDir, 'tasks.md'), '# Tasks');
120
+
121
+ mockPrompt.mockResolvedValue({
122
+ displayName: 'Spec Kit Spec',
123
+ description: 'A spec kit spec for the marketplace.',
124
+ replacesSaas: '',
125
+ outputType: 'web-app',
126
+ primaryStack: 'nextjs-typescript',
127
+ });
128
+
129
+ await handleInit({ path: tmpDir });
130
+
131
+ await expect(access(join(tmpDir, SIDECAR_FILENAME))).resolves.toBeUndefined();
132
+ const sidecar = await readFile(join(tmpDir, SIDECAR_FILENAME), 'utf-8');
133
+ expect(sidecar).toContain('spec_format: speckit');
134
+ expect(sidecar).toContain('display_name: "Spec Kit Spec"');
135
+ const specMd = await readFile(join(tmpDir, 'spec.md'), 'utf-8');
136
+ expect(specMd).toBe('# My Spec Kit spec\nDo not overwrite.');
137
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Added specmarket.yaml'));
138
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('specmarket validate'));
139
+ });
140
+
141
+ it('exits without overwriting when specmarket.yaml already exists in -p dir', async () => {
142
+ await mkdir(tmpDir, { recursive: true });
143
+ await writeFile(
144
+ join(tmpDir, SIDECAR_FILENAME),
145
+ 'spec_format: speckit\ndisplay_name: X\ndescription: Already have a sidecar here.\noutput_type: web-app\nprimary_stack: nextjs-typescript\n'
146
+ );
147
+
148
+ await handleInit({ path: tmpDir });
149
+
150
+ expect(consoleSpy).toHaveBeenCalledWith(
151
+ expect.stringContaining('already exists')
152
+ );
153
+ const sidecar = await readFile(join(tmpDir, SIDECAR_FILENAME), 'utf-8');
154
+ expect(sidecar).toContain('Already have a sidecar here.');
155
+ });
156
+
157
+ // --from flag tests (import-only mode)
158
+
159
+ it('--from: adds specmarket.yaml sidecar to existing Spec Kit dir', async () => {
160
+ await mkdir(tmpDir, { recursive: true });
161
+ await writeFile(join(tmpDir, 'spec.md'), '# My Spec Kit spec');
162
+ await writeFile(join(tmpDir, 'tasks.md'), '# Tasks');
163
+
164
+ mockPrompt.mockResolvedValue({
165
+ displayName: 'Imported Spec',
166
+ description: 'An imported spec kit spec for testing.',
167
+ replacesSaas: '',
168
+ outputType: 'web-app',
169
+ primaryStack: 'nextjs-typescript',
170
+ });
171
+
172
+ await handleInit({ from: tmpDir });
173
+
174
+ await expect(access(join(tmpDir, SIDECAR_FILENAME))).resolves.toBeUndefined();
175
+ const sidecar = await readFile(join(tmpDir, SIDECAR_FILENAME), 'utf-8');
176
+ expect(sidecar).toContain('spec_format: speckit');
177
+ expect(sidecar).toContain('display_name: "Imported Spec"');
178
+ // Original files must not be modified
179
+ const specMd = await readFile(join(tmpDir, 'spec.md'), 'utf-8');
180
+ expect(specMd).toBe('# My Spec Kit spec');
181
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Added specmarket.yaml'));
182
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('specmarket validate'));
183
+ });
184
+
185
+ it('--from: adds specmarket.yaml sidecar to BMAD dir', async () => {
186
+ await mkdir(tmpDir, { recursive: true });
187
+ await writeFile(join(tmpDir, 'prd.md'), '# PRD');
188
+ await writeFile(join(tmpDir, 'architecture.md'), '# Architecture');
189
+
190
+ mockPrompt.mockResolvedValue({
191
+ displayName: 'BMAD App',
192
+ description: 'A bmad formatted spec for the marketplace.',
193
+ replacesSaas: 'Jira',
194
+ outputType: 'web-app',
195
+ primaryStack: 'nextjs-typescript',
196
+ });
197
+
198
+ await handleInit({ from: tmpDir });
199
+
200
+ const sidecar = await readFile(join(tmpDir, SIDECAR_FILENAME), 'utf-8');
201
+ expect(sidecar).toContain('spec_format: bmad');
202
+ expect(sidecar).toContain('display_name: "BMAD App"');
203
+ });
204
+
205
+ it('--from: errors when directory does not exist', async () => {
206
+ const nonExistentDir = join(tmpDir, 'does-not-exist');
207
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
208
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((_code?: number | string | null) => { throw new Error('process.exit called'); });
209
+
210
+ await expect(handleInit({ from: nonExistentDir })).rejects.toThrow('process.exit called');
211
+
212
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Directory not found'));
213
+ exitSpy.mockRestore();
214
+ consoleErrorSpy.mockRestore();
215
+ });
216
+
217
+ it('--from: errors when directory is empty', async () => {
218
+ await mkdir(tmpDir, { recursive: true });
219
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
220
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((_code?: number | string | null) => { throw new Error('process.exit called'); });
221
+
222
+ await expect(handleInit({ from: tmpDir })).rejects.toThrow('process.exit called');
223
+
224
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Directory is empty'));
225
+ exitSpy.mockRestore();
226
+ consoleErrorSpy.mockRestore();
227
+ });
228
+
229
+ it('--from: warns and exits without overwriting when specmarket.yaml already exists', async () => {
230
+ await mkdir(tmpDir, { recursive: true });
231
+ await writeFile(join(tmpDir, 'spec.md'), '# Spec');
232
+ await writeFile(
233
+ join(tmpDir, SIDECAR_FILENAME),
234
+ 'spec_format: speckit\ndisplay_name: Existing\ndescription: Already have sidecar.\noutput_type: web-app\nprimary_stack: nextjs-typescript\n'
235
+ );
236
+
237
+ await handleInit({ from: tmpDir });
238
+
239
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('already exists'));
240
+ const sidecar = await readFile(join(tmpDir, SIDECAR_FILENAME), 'utf-8');
241
+ expect(sidecar).toContain('Already have sidecar.');
242
+ // mockPrompt should NOT have been called (no prompts for metadata)
243
+ expect(mockPrompt).not.toHaveBeenCalled();
244
+ });
245
+ });