@specmarket/cli 0.0.5 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specmarket/cli",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "type": "module",
5
5
  "description": "SpecMarket CLI - discover, validate, execute, and publish AI specs",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { readFile, rm, access } from 'fs/promises';
2
+ import { readFile, rm, access, writeFile, mkdir } from 'fs/promises';
3
3
  import { join } from 'path';
4
4
  import { tmpdir } from 'os';
5
5
  import { randomUUID } from 'crypto';
@@ -29,6 +29,7 @@ vi.mock('inquirer', () => ({
29
29
  const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
30
30
 
31
31
  import { handleInit } from './init.js';
32
+ import { SIDECAR_FILENAME } from '@specmarket/shared';
32
33
 
33
34
  describe('handleInit', () => {
34
35
  let tmpDir: string;
@@ -43,17 +44,21 @@ describe('handleInit', () => {
43
44
  });
44
45
 
45
46
  it('creates spec directory with all required files', async () => {
46
- mockPrompt.mockResolvedValue({
47
- name: 'test-spec',
48
- displayName: 'Test Spec',
49
- replacesSaas: '',
50
- outputType: 'web-app',
51
- primaryStack: 'nextjs-typescript',
52
- });
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
+ });
53
57
 
54
58
  await handleInit({ path: tmpDir });
55
59
 
56
- // Verify all required files were created
60
+ // Verify all required files were created (specmarket.yaml is required for validate/publish)
61
+ await expect(access(join(tmpDir, SIDECAR_FILENAME))).resolves.toBeUndefined();
57
62
  await expect(access(join(tmpDir, 'spec.yaml'))).resolves.toBeUndefined();
58
63
  await expect(access(join(tmpDir, 'PROMPT.md'))).resolves.toBeUndefined();
59
64
  await expect(access(join(tmpDir, 'SPEC.md'))).resolves.toBeUndefined();
@@ -67,13 +72,15 @@ describe('handleInit', () => {
67
72
  });
68
73
 
69
74
  it('writes correct spec.yaml content based on prompts', async () => {
70
- mockPrompt.mockResolvedValue({
71
- name: 'my-project',
72
- displayName: 'My Project',
73
- replacesSaas: 'Notion',
74
- outputType: 'web-app',
75
- primaryStack: 'nextjs-typescript',
76
- });
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
+ });
77
84
 
78
85
  await handleInit({ path: tmpDir });
79
86
 
@@ -86,13 +93,15 @@ describe('handleInit', () => {
86
93
  });
87
94
 
88
95
  it('shows next-steps instructions after creation', async () => {
89
- mockPrompt.mockResolvedValue({
90
- name: 'test-spec',
91
- displayName: 'Test Spec',
92
- replacesSaas: '',
93
- outputType: 'cli-tool',
94
- primaryStack: 'go',
95
- });
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
+ });
96
105
 
97
106
  await handleInit({ path: tmpDir });
98
107
 
@@ -103,4 +112,134 @@ describe('handleInit', () => {
103
112
  expect.stringContaining('specmarket validate')
104
113
  );
105
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
+ });
106
245
  });
@@ -1,13 +1,54 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import ora from 'ora';
4
- import { mkdir, writeFile } from 'fs/promises';
5
- import { join, resolve } from 'path';
6
- import { EXIT_CODES } from '@specmarket/shared';
4
+ import { mkdir, writeFile, readdir } from 'fs/promises';
5
+ import { join, resolve, basename } from 'path';
6
+ import { EXIT_CODES, SIDECAR_FILENAME } from '@specmarket/shared';
7
7
  import createDebug from 'debug';
8
+ import { detectSpecFormat, fileExists, directoryExists } from '../lib/format-detection.js';
8
9
 
9
10
  const debug = createDebug('specmarket:cli');
10
11
 
12
+ type MetadataData = {
13
+ displayName: string;
14
+ description: string;
15
+ replacesSaas?: string;
16
+ outputType: string;
17
+ primaryStack: string;
18
+ };
19
+
20
+ /** Build specmarket.yaml for any spec format (single source of truth for validate/publish) */
21
+ function buildSpecmarketYaml(data: MetadataData & { specFormat: string }): string {
22
+ return `# SpecMarket metadata (required for validate/publish)
23
+ # Single source of truth for format and marketplace fields.
24
+ # Your existing spec files (Spec Kit, BMAD, Ralph, etc.) are not modified.
25
+
26
+ spec_format: ${data.specFormat}
27
+ display_name: "${data.displayName.replace(/"/g, '\\"')}"
28
+ description: "${data.description.replace(/"/g, '\\"')}"
29
+ output_type: ${data.outputType}
30
+ primary_stack: ${data.primaryStack}
31
+ ${data.replacesSaas ? `replaces_saas: "${data.replacesSaas.replace(/"/g, '\\"')}"` : '# replaces_saas: "ProductName"'}
32
+ # replaces_pricing: "$0-16/mo"
33
+ tags: []
34
+ estimated_tokens: 50000
35
+ estimated_cost_usd: 2.50
36
+ estimated_time_minutes: 30
37
+ `;
38
+ }
39
+
40
+ /** specmarket.yaml for new SpecMarket (classic) specs */
41
+ const SPECMARKET_YAML_TEMPLATE = (data: {
42
+ displayName: string;
43
+ replacesSaas?: string;
44
+ outputType: string;
45
+ primaryStack: string;
46
+ }) => buildSpecmarketYaml({
47
+ ...data,
48
+ specFormat: 'specmarket',
49
+ description: `A ${data.outputType} spec${data.replacesSaas ? ` that replaces ${data.replacesSaas}` : ''}.`,
50
+ });
51
+
11
52
  const SPEC_YAML_TEMPLATE = (data: {
12
53
  name: string;
13
54
  displayName: string;
@@ -162,12 +203,274 @@ const TASKS_MD_TEMPLATE = (displayName: string) => `# Tasks
162
203
  ## Discovered Issues
163
204
  `;
164
205
 
206
+ /** Prompt for marketplace metadata only (used when adding sidecar to existing spec) */
207
+ async function promptMetadataOnly(defaultDisplayName?: string): Promise<MetadataData> {
208
+ const { default: inquirer } = await import('inquirer');
209
+ const answers = await inquirer.prompt<{
210
+ displayName: string;
211
+ description: string;
212
+ replacesSaas: string;
213
+ outputType: string;
214
+ primaryStack: string;
215
+ }>([
216
+ {
217
+ type: 'input',
218
+ name: 'displayName',
219
+ message: 'Display name for the marketplace:',
220
+ default: defaultDisplayName ?? 'My Spec',
221
+ },
222
+ {
223
+ type: 'input',
224
+ name: 'description',
225
+ message: 'Short description (min 10 characters):',
226
+ default: 'A spec ready to validate and publish on SpecMarket.',
227
+ validate: (v: string) => (v.length >= 10 ? true : 'Description must be at least 10 characters'),
228
+ },
229
+ {
230
+ type: 'input',
231
+ name: 'replacesSaas',
232
+ message: 'What SaaS product does this replace? (optional, Enter to skip):',
233
+ default: '',
234
+ },
235
+ {
236
+ type: 'list',
237
+ name: 'outputType',
238
+ message: 'Output type:',
239
+ choices: [
240
+ { name: 'Web Application', value: 'web-app' },
241
+ { name: 'CLI Tool', value: 'cli-tool' },
242
+ { name: 'API Service', value: 'api-service' },
243
+ { name: 'Library/Package', value: 'library' },
244
+ { name: 'Mobile App', value: 'mobile-app' },
245
+ ],
246
+ },
247
+ {
248
+ type: 'list',
249
+ name: 'primaryStack',
250
+ message: 'Primary stack:',
251
+ choices: [
252
+ { name: 'Next.js + TypeScript', value: 'nextjs-typescript' },
253
+ { name: 'Astro + TypeScript', value: 'astro-typescript' },
254
+ { name: 'Python + FastAPI', value: 'python-fastapi' },
255
+ { name: 'Go', value: 'go' },
256
+ { name: 'Rust', value: 'rust' },
257
+ { name: 'Other', value: 'other' },
258
+ ],
259
+ },
260
+ ]);
261
+ return {
262
+ displayName: answers.displayName,
263
+ description: answers.description,
264
+ replacesSaas: answers.replacesSaas || undefined,
265
+ outputType: answers.outputType,
266
+ primaryStack: answers.primaryStack,
267
+ };
268
+ }
269
+
270
+ /** Returns true if directory exists and has at least one file (not just subdirs). */
271
+ async function dirHasFiles(dir: string): Promise<boolean> {
272
+ try {
273
+ const entries = await readdir(dir, { withFileTypes: true });
274
+ return entries.some((e) => e.isFile());
275
+ } catch {
276
+ return false;
277
+ }
278
+ }
279
+
165
280
  export async function handleInit(opts: {
166
281
  name?: string;
167
282
  path?: string;
283
+ from?: string;
168
284
  }): Promise<void> {
169
285
  const { default: inquirer } = await import('inquirer');
170
286
 
287
+ // --from: strict import-only mode. Errors if directory doesn't exist or has no spec files.
288
+ if (opts.from !== undefined && opts.from !== '') {
289
+ const targetDir = resolve(opts.from);
290
+
291
+ const dirExists = await directoryExists(targetDir);
292
+ if (!dirExists) {
293
+ console.error(chalk.red(`Directory not found: ${targetDir}`));
294
+ console.error(chalk.gray('--from requires an existing spec directory. Run specmarket init (no flags) to create a new spec from scratch.'));
295
+ process.exit(EXIT_CODES.INVALID_SPEC);
296
+ }
297
+
298
+ const hasAnyFiles = await dirHasFiles(targetDir);
299
+ if (!hasAnyFiles) {
300
+ console.error(chalk.red(`Directory is empty: ${targetDir}`));
301
+ console.error(chalk.gray('--from requires a directory with spec files (Spec Kit, BMAD, Ralph, or custom markdown). Run specmarket init to create a new spec.'));
302
+ process.exit(EXIT_CODES.INVALID_SPEC);
303
+ }
304
+
305
+ const sidecarPath = join(targetDir, SIDECAR_FILENAME);
306
+ if (await fileExists(sidecarPath)) {
307
+ console.log(chalk.yellow(`${SIDECAR_FILENAME} already exists in this directory.`));
308
+ console.log(chalk.gray('Run specmarket validate to check your spec, then specmarket publish to publish.'));
309
+ return;
310
+ }
311
+
312
+ const detection = await detectSpecFormat(targetDir);
313
+ const formatLabel =
314
+ detection.format === 'specmarket'
315
+ ? 'SpecMarket (spec.yaml + PROMPT.md + …)'
316
+ : detection.format === 'speckit'
317
+ ? 'Spec Kit'
318
+ : detection.format === 'bmad'
319
+ ? 'BMAD'
320
+ : detection.format === 'ralph'
321
+ ? 'Ralph'
322
+ : 'custom markdown';
323
+ console.log(chalk.gray(`Detected ${formatLabel} spec. Adding SpecMarket metadata only; your files will not be modified.`));
324
+ console.log('');
325
+
326
+ const metadata = await promptMetadataOnly(basename(targetDir));
327
+ const yaml = buildSpecmarketYaml({ ...metadata, specFormat: detection.format });
328
+ await writeFile(sidecarPath, yaml);
329
+
330
+ console.log('');
331
+ console.log(chalk.green(`Added ${SIDECAR_FILENAME} to ${targetDir}`));
332
+ console.log('');
333
+ console.log(chalk.bold('Next steps:'));
334
+ console.log(` 1. Run ${chalk.cyan('specmarket validate')} to check your spec`);
335
+ console.log(` 2. Run ${chalk.cyan('specmarket publish')} to publish to the marketplace`);
336
+ console.log(` 3. Run ${chalk.cyan('specmarket run')} to execute the spec locally`);
337
+ return;
338
+ }
339
+
340
+ // When -p/--path is set, we're initializing in an existing directory (safe add-sidecar flow)
341
+ if (opts.path !== undefined && opts.path !== '') {
342
+ const targetDir = resolve(opts.path);
343
+ await mkdir(targetDir, { recursive: true });
344
+
345
+ const sidecarPath = join(targetDir, SIDECAR_FILENAME);
346
+ if (await fileExists(sidecarPath)) {
347
+ console.log(chalk.yellow(`${SIDECAR_FILENAME} already exists in this directory.`));
348
+ console.log(chalk.gray('Run specmarket validate to check your spec, then specmarket publish to publish.'));
349
+ return;
350
+ }
351
+
352
+ const detection = await detectSpecFormat(targetDir);
353
+ const hasAnyFiles = await dirHasFiles(targetDir);
354
+
355
+ if (hasAnyFiles && detection.format !== 'custom') {
356
+ // Recognized format (specmarket, speckit, bmad, ralph): add sidecar only, no overwrites
357
+ const formatLabel =
358
+ detection.format === 'specmarket'
359
+ ? 'SpecMarket (spec.yaml + PROMPT.md + …)'
360
+ : detection.format === 'speckit'
361
+ ? 'Spec Kit'
362
+ : detection.format === 'bmad'
363
+ ? 'BMAD'
364
+ : detection.format === 'ralph'
365
+ ? 'Ralph'
366
+ : detection.format;
367
+ console.log(chalk.gray(`Detected ${formatLabel} spec. Adding SpecMarket metadata only; your files will not be modified.`));
368
+ console.log('');
369
+
370
+ const metadata = await promptMetadataOnly(basename(targetDir));
371
+ const yaml = buildSpecmarketYaml({
372
+ ...metadata,
373
+ specFormat: detection.format,
374
+ });
375
+ await writeFile(sidecarPath, yaml);
376
+
377
+ console.log('');
378
+ console.log(chalk.green(`Added ${SIDECAR_FILENAME} to ${targetDir}`));
379
+ console.log('');
380
+ console.log(chalk.bold('Next steps:'));
381
+ console.log(` 1. Run ${chalk.cyan('specmarket validate')} to check your spec`);
382
+ console.log(` 2. Run ${chalk.cyan('specmarket publish')} to publish to the marketplace`);
383
+ console.log(` 3. Run ${chalk.cyan('specmarket run')} to execute the spec locally`);
384
+ return;
385
+ }
386
+
387
+ if (hasAnyFiles && detection.format === 'custom') {
388
+ // Custom (e.g. random .md): still add sidecar so they can validate/publish
389
+ console.log(chalk.gray('Detected markdown spec. Adding SpecMarket metadata only; your files will not be modified.'));
390
+ console.log('');
391
+ const metadata = await promptMetadataOnly(basename(targetDir));
392
+ const yaml = buildSpecmarketYaml({
393
+ ...metadata,
394
+ specFormat: 'custom',
395
+ });
396
+ await writeFile(sidecarPath, yaml);
397
+ console.log('');
398
+ console.log(chalk.green(`Added ${SIDECAR_FILENAME} to ${targetDir}`));
399
+ console.log('');
400
+ console.log(chalk.bold('Next steps:'));
401
+ console.log(` 1. Run ${chalk.cyan('specmarket validate')} to check your spec`);
402
+ console.log(` 2. Run ${chalk.cyan('specmarket publish')} to publish to the marketplace`);
403
+ return;
404
+ }
405
+
406
+ // Empty directory: offer to create from scratch
407
+ console.log(chalk.gray('Directory is empty or has no recognized spec format.'));
408
+ const { createNew } = await inquirer.prompt<{ createNew: boolean }>([
409
+ {
410
+ type: 'confirm',
411
+ name: 'createNew',
412
+ message: 'Create a new SpecMarket spec from scratch here?',
413
+ default: true,
414
+ },
415
+ ]);
416
+ if (!createNew) {
417
+ console.log(chalk.gray('Exiting. Add spec files (e.g. Spec Kit, BMAD) and run specmarket init -p . again.'));
418
+ return;
419
+ }
420
+ const spinner = ora(`Creating spec at ${targetDir}...`).start();
421
+ const fullAnswers = await inquirer.prompt<{
422
+ name: string;
423
+ displayName: string;
424
+ replacesSaas: string;
425
+ outputType: string;
426
+ primaryStack: string;
427
+ }>([
428
+ { type: 'input', name: 'name', message: 'Spec name (lowercase, hyphens only):', default: opts.name ?? (basename(targetDir) || 'my-spec'), validate: (v: string) => /^[a-z0-9-]+$/.test(v) || 'Must be lowercase alphanumeric with hyphens' },
429
+ { type: 'input', name: 'displayName', message: 'Display name:', default: (a: { name: string }) => a.name.split('-').map((w) => w[0]?.toUpperCase() + w.slice(1)).join(' ') },
430
+ { type: 'input', name: 'replacesSaas', message: 'What SaaS product does this replace? (optional):', default: '' },
431
+ { type: 'list', name: 'outputType', message: 'Output type:', choices: [
432
+ { name: 'Web Application', value: 'web-app' },
433
+ { name: 'CLI Tool', value: 'cli-tool' },
434
+ { name: 'API Service', value: 'api-service' },
435
+ { name: 'Library/Package', value: 'library' },
436
+ { name: 'Mobile App', value: 'mobile-app' },
437
+ ]},
438
+ { type: 'list', name: 'primaryStack', message: 'Primary stack:', choices: [
439
+ { name: 'Next.js + TypeScript', value: 'nextjs-typescript' },
440
+ { name: 'Astro + TypeScript', value: 'astro-typescript' },
441
+ { name: 'Python + FastAPI', value: 'python-fastapi' },
442
+ { name: 'Go', value: 'go' },
443
+ { name: 'Rust', value: 'rust' },
444
+ { name: 'Other', value: 'other' },
445
+ ]},
446
+ ]);
447
+ const data = {
448
+ name: fullAnswers.name,
449
+ displayName: fullAnswers.displayName,
450
+ replacesSaas: fullAnswers.replacesSaas || undefined,
451
+ outputType: fullAnswers.outputType,
452
+ primaryStack: fullAnswers.primaryStack,
453
+ };
454
+ await mkdir(join(targetDir, 'stdlib'), { recursive: true });
455
+ await Promise.all([
456
+ writeFile(sidecarPath, SPECMARKET_YAML_TEMPLATE(data)),
457
+ writeFile(join(targetDir, 'spec.yaml'), SPEC_YAML_TEMPLATE(data)),
458
+ writeFile(join(targetDir, 'PROMPT.md'), PROMPT_MD_TEMPLATE(data)),
459
+ writeFile(join(targetDir, 'SPEC.md'), SPEC_MD_TEMPLATE(data)),
460
+ writeFile(join(targetDir, 'SUCCESS_CRITERIA.md'), SUCCESS_CRITERIA_TEMPLATE),
461
+ writeFile(join(targetDir, 'stdlib', 'STACK.md'), STACK_MD_TEMPLATE(fullAnswers.primaryStack)),
462
+ writeFile(join(targetDir, 'TASKS.md'), TASKS_MD_TEMPLATE(fullAnswers.displayName)),
463
+ ]);
464
+ spinner.succeed(chalk.green(`Spec created at ${targetDir}`));
465
+ console.log('');
466
+ console.log(chalk.bold('Next steps:'));
467
+ console.log(` 1. Edit ${chalk.cyan('SPEC.md')} with your application requirements`);
468
+ console.log(` 2. Edit ${chalk.cyan('SUCCESS_CRITERIA.md')} with pass/fail criteria`);
469
+ console.log(` 3. Run ${chalk.cyan('specmarket validate')} then ${chalk.cyan('specmarket run')}`);
470
+ return;
471
+ }
472
+
473
+ // No path: create new directory (or add sidecar if that directory already exists with content)
171
474
  const answers = await inquirer.prompt<{
172
475
  name: string;
173
476
  displayName: string;
@@ -187,11 +490,8 @@ export async function handleInit(opts: {
187
490
  type: 'input',
188
491
  name: 'displayName',
189
492
  message: 'Display name:',
190
- default: (answers: { name: string }) =>
191
- answers.name
192
- .split('-')
193
- .map((w) => w[0]?.toUpperCase() + w.slice(1))
194
- .join(' '),
493
+ default: (ans: { name: string }) =>
494
+ ans.name.split('-').map((w) => w[0]?.toUpperCase() + w.slice(1)).join(' '),
195
495
  },
196
496
  {
197
497
  type: 'input',
@@ -226,14 +526,44 @@ export async function handleInit(opts: {
226
526
  },
227
527
  ]);
228
528
 
229
- const targetDir = resolve(opts.path ?? answers.name);
529
+ const targetDir = resolve(answers.name);
230
530
  const spinner = ora(`Creating spec directory at ${targetDir}...`).start();
231
531
 
232
532
  try {
233
- // Create directory structure
234
533
  await mkdir(targetDir, { recursive: true });
235
- await mkdir(join(targetDir, 'stdlib'), { recursive: true });
236
534
 
535
+ const sidecarPath = join(targetDir, SIDECAR_FILENAME);
536
+ if (await fileExists(sidecarPath)) {
537
+ spinner.stop();
538
+ console.log(chalk.yellow(`${SIDECAR_FILENAME} already exists in ${targetDir}.`));
539
+ console.log(chalk.gray('Run specmarket validate to check your spec, then specmarket publish to publish.'));
540
+ return;
541
+ }
542
+
543
+ const hasFiles = await dirHasFiles(targetDir);
544
+ if (hasFiles) {
545
+ const detection = await detectSpecFormat(targetDir);
546
+ spinner.stop();
547
+ console.log(chalk.gray(`Directory already has files (detected: ${detection.format}). Adding ${SIDECAR_FILENAME} only; no files overwritten.`));
548
+ const metadata: MetadataData = {
549
+ displayName: answers.displayName,
550
+ description: `A ${answers.outputType} spec${answers.replacesSaas ? ` that replaces ${answers.replacesSaas}` : ''}.`,
551
+ replacesSaas: answers.replacesSaas || undefined,
552
+ outputType: answers.outputType,
553
+ primaryStack: answers.primaryStack,
554
+ };
555
+ const yaml = buildSpecmarketYaml({ ...metadata, specFormat: detection.format });
556
+ await writeFile(sidecarPath, yaml);
557
+ console.log('');
558
+ console.log(chalk.green(`Added ${SIDECAR_FILENAME} to ${targetDir}`));
559
+ console.log('');
560
+ console.log(chalk.bold('Next steps:'));
561
+ console.log(` 1. Run ${chalk.cyan('specmarket validate')} to check your spec`);
562
+ console.log(` 2. Run ${chalk.cyan('specmarket publish')} to publish to the marketplace`);
563
+ return;
564
+ }
565
+
566
+ await mkdir(join(targetDir, 'stdlib'), { recursive: true });
237
567
  const data = {
238
568
  name: answers.name,
239
569
  displayName: answers.displayName,
@@ -242,8 +572,8 @@ export async function handleInit(opts: {
242
572
  primaryStack: answers.primaryStack,
243
573
  };
244
574
 
245
- // Write all template files
246
575
  await Promise.all([
576
+ writeFile(sidecarPath, SPECMARKET_YAML_TEMPLATE(data)),
247
577
  writeFile(join(targetDir, 'spec.yaml'), SPEC_YAML_TEMPLATE(data)),
248
578
  writeFile(join(targetDir, 'PROMPT.md'), PROMPT_MD_TEMPLATE(data)),
249
579
  writeFile(join(targetDir, 'SPEC.md'), SPEC_MD_TEMPLATE(data)),
@@ -253,14 +583,13 @@ export async function handleInit(opts: {
253
583
  ]);
254
584
 
255
585
  spinner.succeed(chalk.green(`Spec created at ${targetDir}`));
256
-
257
586
  console.log('');
258
587
  console.log(chalk.bold('Next steps:'));
259
588
  console.log(` 1. ${chalk.cyan(`cd ${answers.name}`)}`);
260
589
  console.log(` 2. Edit ${chalk.cyan('SPEC.md')} with your application requirements`);
261
590
  console.log(` 3. Edit ${chalk.cyan('SUCCESS_CRITERIA.md')} with specific pass/fail criteria`);
262
- console.log(` 4. Run ${chalk.cyan(`specmarket validate`)} to check your spec`);
263
- console.log(` 5. Run ${chalk.cyan(`specmarket run`)} to execute the spec`);
591
+ console.log(` 4. Run ${chalk.cyan('specmarket validate')} to check your spec`);
592
+ console.log(` 5. Run ${chalk.cyan('specmarket run')} to execute the spec`);
264
593
  } catch (err) {
265
594
  spinner.fail(chalk.red(`Failed to create spec: ${(err as Error).message}`));
266
595
  throw err;
@@ -269,9 +598,12 @@ export async function handleInit(opts: {
269
598
 
270
599
  export function createInitCommand(): Command {
271
600
  return new Command('init')
272
- .description('Create a new spec directory with template files')
601
+ .description(
602
+ 'Create a new SpecMarket spec or add specmarket.yaml to an existing spec (Spec Kit, BMAD, Ralph). Use -p . to init in current directory without overwriting existing files.'
603
+ )
273
604
  .option('-n, --name <name>', 'Spec name (skip prompt)')
274
- .option('-p, --path <path>', 'Target directory path (defaults to spec name)')
605
+ .option('-p, --path <path>', 'Target directory (e.g. . or ./my-spec). When set, detects existing format and adds only specmarket.yaml if present; no files overwritten.')
606
+ .option('--from <path>', 'Import an existing spec directory (Spec Kit, BMAD, Ralph, or custom markdown). Detects format and adds specmarket.yaml metadata sidecar without modifying original files. Errors if the directory is missing or empty.')
275
607
  .action(async (opts) => {
276
608
  try {
277
609
  await handleInit(opts);