@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
@@ -1,189 +1,61 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { readFile, readdir, access, stat } from 'fs/promises';
3
+ import { readFile, readdir, access } from 'fs/promises';
4
4
  import { join, resolve, relative, normalize } from 'path';
5
5
  import { parse as parseYaml } from 'yaml';
6
6
  import {
7
7
  specYamlSchema,
8
+ specmarketSidecarSchema,
8
9
  EXIT_CODES,
9
10
  REQUIRED_SPEC_FILES,
10
11
  REQUIRED_STDLIB_FILES,
12
+ SIDECAR_FILENAME,
11
13
  } from '@specmarket/shared';
12
14
  import type { ValidationResult } from '@specmarket/shared';
15
+ import {
16
+ fileExists,
17
+ directoryExists,
18
+ hasStoryFiles,
19
+ hasMarkdownFiles,
20
+ } from '../lib/format-detection.js';
13
21
 
14
22
  /**
15
- * Validates a spec directory for completeness and schema compliance.
16
- *
17
- * Checks (errors):
18
- * - spec.yaml parses and conforms to Zod schema
19
- * - Required files exist and are non-empty: PROMPT.md, SPEC.md, SUCCESS_CRITERIA.md
20
- * - stdlib/ directory with STACK.md
21
- * - SUCCESS_CRITERIA.md has at least one criterion
22
- * - No circular references between spec files (Markdown link cycles)
23
- * - Infrastructure block validates (if present)
24
- *
25
- * Checks (warnings):
26
- * - Web-app/api-service/mobile-app with no infrastructure services
27
- * - Missing setup_time_minutes
28
- * - Token/cost/time estimates seem unreasonably low or high
29
- *
30
- * Exported as a testable function separate from the CLI command wrapper.
23
+ * Recursively collects file paths relative to baseDir, filtered by extension.
31
24
  */
32
- export async function validateSpec(specPath: string): Promise<ValidationResult> {
33
- const dir = resolve(specPath);
34
- const errors: string[] = [];
35
- const warnings: string[] = [];
36
-
37
- // Check required files exist and are non-empty
38
- for (const file of REQUIRED_SPEC_FILES) {
39
- const filePath = join(dir, file);
40
- try {
41
- await access(filePath);
42
- const content = await readFile(filePath, 'utf-8');
43
- if (content.trim().length === 0) {
44
- errors.push(`${file} exists but is empty`);
45
- }
46
- } catch {
47
- errors.push(`Required file missing: ${file}`);
48
- }
49
- }
50
-
51
- // Check stdlib directory
52
- const stdlibDir = join(dir, 'stdlib');
53
- for (const file of REQUIRED_STDLIB_FILES) {
54
- const filePath = join(stdlibDir, file);
55
- try {
56
- await access(filePath);
57
- const content = await readFile(filePath, 'utf-8');
58
- if (content.trim().length === 0) {
59
- errors.push(`stdlib/${file} exists but is empty`);
60
- }
61
- } catch {
62
- errors.push(`Required file missing: stdlib/${file}`);
63
- }
64
- }
65
-
66
- // Parse and validate spec.yaml
67
- let specYaml: unknown = null;
68
- const specYamlPath = join(dir, 'spec.yaml');
25
+ async function collectFiles(
26
+ currentDir: string,
27
+ baseDir: string,
28
+ extensions: Set<string>
29
+ ): Promise<string[]> {
30
+ const results: string[] = [];
69
31
  try {
70
- const raw = await readFile(specYamlPath, 'utf-8');
71
- specYaml = parseYaml(raw);
72
- } catch (err) {
73
- errors.push(`spec.yaml: Failed to parse YAML: ${(err as Error).message}`);
74
- return { valid: false, errors, warnings };
75
- }
76
-
77
- const parseResult = specYamlSchema.safeParse(specYaml);
78
- if (!parseResult.success) {
79
- for (const issue of parseResult.error.issues) {
80
- errors.push(
81
- `spec.yaml: ${issue.path.join('.')} — ${issue.message}`
82
- );
83
- }
84
- } else {
85
- const parsed = parseResult.data;
86
-
87
- // Check SUCCESS_CRITERIA.md has at least one criterion
88
- try {
89
- const criteriaContent = await readFile(join(dir, 'SUCCESS_CRITERIA.md'), 'utf-8');
90
- const hasCriterion = /^- \[[ x]\]/m.test(criteriaContent);
91
- if (!hasCriterion) {
92
- errors.push(
93
- 'SUCCESS_CRITERIA.md: Must contain at least one criterion in format: - [ ] criterion text'
94
- );
95
- }
96
- } catch {
97
- // Already caught in required files check
98
- }
99
-
100
- // Check for circular references between spec files
101
- const cycles = await detectCircularReferences(dir);
102
- for (const cycle of cycles) {
103
- errors.push(`Circular reference detected: ${cycle}`);
104
- }
105
-
106
- // Infrastructure validation warnings
107
- if (parsed.infrastructure) {
108
- const infra = parsed.infrastructure;
109
-
110
- // Warn if web/api/mobile apps have no services
111
- if (
112
- ['web-app', 'api-service', 'mobile-app'].includes(parsed.output_type) &&
113
- infra.services.length === 0
114
- ) {
115
- warnings.push(
116
- `${parsed.output_type} should typically define infrastructure services (database, auth, etc.)`
117
- );
118
- }
119
-
120
- if (!infra.setup_time_minutes) {
121
- warnings.push('infrastructure.setup_time_minutes is not set');
122
- }
123
-
124
- // Validate defaultProvider matches a defined provider name
125
- for (const service of infra.services) {
126
- if (service.default_provider) {
127
- const providerNames = service.providers.map((p) => p.name);
128
- if (!providerNames.includes(service.default_provider)) {
129
- errors.push(
130
- `infrastructure.services[${service.name}].default_provider "${service.default_provider}" ` +
131
- `does not match any defined provider (${providerNames.join(', ')})`
132
- );
133
- }
32
+ const entries = await readdir(currentDir, { withFileTypes: true });
33
+ for (const entry of entries) {
34
+ const fullPath = join(currentDir, entry.name);
35
+ if (entry.isDirectory()) {
36
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
37
+ const subFiles = await collectFiles(fullPath, baseDir, extensions);
38
+ results.push(...subFiles);
39
+ } else if (entry.isFile()) {
40
+ const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop()! : '';
41
+ if (extensions.has(ext)) {
42
+ results.push(relative(baseDir, fullPath));
134
43
  }
135
44
  }
136
- } else {
137
- if (['web-app', 'api-service', 'mobile-app'].includes(parsed.output_type)) {
138
- warnings.push(
139
- 'No infrastructure block defined. Consider adding infrastructure.services for database, auth, etc.'
140
- );
141
- }
142
- }
143
-
144
- // Estimate sanity checks
145
- if (parsed.estimated_tokens < 1000) {
146
- warnings.push(
147
- `estimated_tokens (${parsed.estimated_tokens}) seems very low. Most specs use 10,000+.`
148
- );
149
- }
150
- if (parsed.estimated_tokens > 10_000_000) {
151
- warnings.push(
152
- `estimated_tokens (${parsed.estimated_tokens}) seems very high.`
153
- );
154
- }
155
- if (parsed.estimated_cost_usd < 0.01) {
156
- warnings.push(
157
- `estimated_cost_usd ($${parsed.estimated_cost_usd}) seems very low.`
158
- );
159
- }
160
- if (parsed.estimated_time_minutes < 1) {
161
- warnings.push(
162
- `estimated_time_minutes (${parsed.estimated_time_minutes}) seems unrealistically low.`
163
- );
164
45
  }
46
+ } catch {
47
+ // Directory unreadable
165
48
  }
166
-
167
- return {
168
- valid: errors.length === 0,
169
- errors,
170
- warnings,
171
- };
49
+ return results;
172
50
  }
173
51
 
174
52
  /**
175
53
  * Scans all Markdown/YAML files in the spec directory for relative file
176
54
  * references (Markdown links) and detects cycles in the reference graph.
177
- *
178
- * Returns an array of cycle descriptions (empty if no cycles found).
179
- * Uses DFS with coloring (white/gray/black) for cycle detection.
180
55
  */
181
56
  export async function detectCircularReferences(dir: string): Promise<string[]> {
182
- // Collect all text files in the spec directory (md, yaml, yml)
183
57
  const textExtensions = new Set(['.md', '.yaml', '.yml']);
184
58
  const files = await collectFiles(dir, dir, textExtensions);
185
-
186
- // Build adjacency list: file -> files it references via Markdown links
187
59
  const graph = new Map<string, Set<string>>();
188
60
  const linkPattern = /\[(?:[^\]]*)\]\(([^)]+)\)/g;
189
61
 
@@ -194,32 +66,31 @@ export async function detectCircularReferences(dir: string): Promise<string[]> {
194
66
  let match: RegExpExecArray | null;
195
67
  while ((match = linkPattern.exec(content)) !== null) {
196
68
  const target = match[1]!;
197
- // Skip external URLs, anchors, and mailto
198
- if (target.startsWith('http://') || target.startsWith('https://') ||
199
- target.startsWith('#') || target.startsWith('mailto:')) {
69
+ if (
70
+ target.startsWith('http://') ||
71
+ target.startsWith('https://') ||
72
+ target.startsWith('#') ||
73
+ target.startsWith('mailto:')
74
+ ) {
200
75
  continue;
201
76
  }
202
- // Strip anchor fragments (e.g., "./SPEC.md#section" -> "./SPEC.md")
203
77
  const targetPath = target.split('#')[0]!;
204
78
  if (!targetPath) continue;
205
-
206
- // Resolve relative to the file's directory
207
79
  const fileDir = join(dir, file, '..');
208
80
  const resolvedTarget = normalize(relative(dir, resolve(fileDir, targetPath)));
209
-
210
- // Only track references to files within the spec directory
211
81
  if (!resolvedTarget.startsWith('..') && files.includes(resolvedTarget)) {
212
82
  refs.add(resolvedTarget);
213
83
  }
214
84
  }
215
85
  } catch {
216
- // File unreadable — skip
86
+ // skip
217
87
  }
218
88
  graph.set(file, refs);
219
89
  }
220
90
 
221
- // DFS cycle detection
222
- const WHITE = 0, GRAY = 1, BLACK = 2;
91
+ const WHITE = 0,
92
+ GRAY = 1,
93
+ BLACK = 2;
223
94
  const color = new Map<string, number>();
224
95
  const parent = new Map<string, string | null>();
225
96
  const cycles: string[] = [];
@@ -233,7 +104,6 @@ export async function detectCircularReferences(dir: string): Promise<string[]> {
233
104
  const neighbors = graph.get(node) ?? new Set();
234
105
  for (const neighbor of neighbors) {
235
106
  if (color.get(neighbor) === GRAY) {
236
- // Found a cycle — reconstruct the path
237
107
  const cyclePath: string[] = [neighbor, node];
238
108
  let curr = node;
239
109
  while (curr !== neighbor) {
@@ -263,44 +133,297 @@ export async function detectCircularReferences(dir: string): Promise<string[]> {
263
133
  }
264
134
 
265
135
  /**
266
- * Recursively collects file paths relative to baseDir, filtered by extension.
136
+ * Content validation when spec_format is "specmarket" (spec.yaml + PROMPT.md + SPEC.md + SUCCESS_CRITERIA.md + stdlib).
137
+ * Pushes to errors/warnings.
267
138
  */
268
- async function collectFiles(
269
- currentDir: string,
270
- baseDir: string,
271
- extensions: Set<string>
272
- ): Promise<string[]> {
273
- const results: string[] = [];
139
+ async function validateSpecmarketContent(
140
+ dir: string,
141
+ errors: string[],
142
+ warnings: string[]
143
+ ): Promise<void> {
144
+ for (const file of REQUIRED_SPEC_FILES) {
145
+ const filePath = join(dir, file);
146
+ try {
147
+ await access(filePath);
148
+ const content = await readFile(filePath, 'utf-8');
149
+ if (content.trim().length === 0) {
150
+ errors.push(`${file} exists but is empty`);
151
+ }
152
+ } catch {
153
+ errors.push(`Required file missing: ${file}`);
154
+ }
155
+ }
156
+
157
+ const stdlibDir = join(dir, 'stdlib');
158
+ for (const file of REQUIRED_STDLIB_FILES) {
159
+ const filePath = join(stdlibDir, file);
160
+ try {
161
+ await access(filePath);
162
+ const content = await readFile(filePath, 'utf-8');
163
+ if (content.trim().length === 0) {
164
+ errors.push(`stdlib/${file} exists but is empty`);
165
+ }
166
+ } catch {
167
+ errors.push(`Required file missing: stdlib/${file}`);
168
+ }
169
+ }
170
+
171
+ let specYaml: unknown = null;
172
+ const specYamlPath = join(dir, 'spec.yaml');
274
173
  try {
275
- const entries = await readdir(currentDir, { withFileTypes: true });
276
- for (const entry of entries) {
277
- const fullPath = join(currentDir, entry.name);
278
- if (entry.isDirectory()) {
279
- // Skip hidden directories and node_modules
280
- if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
281
- const subFiles = await collectFiles(fullPath, baseDir, extensions);
282
- results.push(...subFiles);
283
- } else if (entry.isFile()) {
284
- const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
285
- if (extensions.has(ext)) {
286
- results.push(relative(baseDir, fullPath));
174
+ const raw = await readFile(specYamlPath, 'utf-8');
175
+ specYaml = parseYaml(raw);
176
+ } catch (err) {
177
+ errors.push(`spec.yaml: Failed to parse YAML: ${(err as Error).message}`);
178
+ return;
179
+ }
180
+
181
+ const parseResult = specYamlSchema.safeParse(specYaml);
182
+ if (!parseResult.success) {
183
+ for (const issue of parseResult.error.issues) {
184
+ errors.push(`spec.yaml: ${issue.path.join('.')} — ${issue.message}`);
185
+ }
186
+ return;
187
+ }
188
+
189
+ const parsed = parseResult.data;
190
+
191
+ try {
192
+ const criteriaContent = await readFile(join(dir, 'SUCCESS_CRITERIA.md'), 'utf-8');
193
+ const hasCriterion = /^- \[[ x]\]/m.test(criteriaContent);
194
+ if (!hasCriterion) {
195
+ errors.push(
196
+ 'SUCCESS_CRITERIA.md: Must contain at least one criterion in format: - [ ] criterion text'
197
+ );
198
+ }
199
+ } catch {
200
+ // Already caught in required files check
201
+ }
202
+
203
+ const cycles = await detectCircularReferences(dir);
204
+ for (const cycle of cycles) {
205
+ errors.push(`Circular reference detected: ${cycle}`);
206
+ }
207
+
208
+ if (parsed.infrastructure) {
209
+ const infra = parsed.infrastructure;
210
+ if (
211
+ ['web-app', 'api-service', 'mobile-app'].includes(parsed.output_type) &&
212
+ infra.services.length === 0
213
+ ) {
214
+ warnings.push(
215
+ `${parsed.output_type} should typically define infrastructure services (database, auth, etc.)`
216
+ );
217
+ }
218
+ if (!infra.setup_time_minutes) {
219
+ warnings.push('infrastructure.setup_time_minutes is not set');
220
+ }
221
+ for (const service of infra.services) {
222
+ if (service.default_provider) {
223
+ const providerNames = service.providers.map((p) => p.name);
224
+ if (!providerNames.includes(service.default_provider)) {
225
+ errors.push(
226
+ `infrastructure.services[${service.name}].default_provider "${service.default_provider}" ` +
227
+ `does not match any defined provider (${providerNames.join(', ')})`
228
+ );
287
229
  }
288
230
  }
289
231
  }
232
+ } else {
233
+ if (['web-app', 'api-service', 'mobile-app'].includes(parsed.output_type)) {
234
+ warnings.push(
235
+ 'No infrastructure block defined. Consider adding infrastructure.services for database, auth, etc.'
236
+ );
237
+ }
238
+ }
239
+
240
+ if (parsed.estimated_tokens < 1000) {
241
+ warnings.push(
242
+ `estimated_tokens (${parsed.estimated_tokens}) seems very low. Most specs use 10,000+.`
243
+ );
244
+ }
245
+ if (parsed.estimated_tokens > 10_000_000) {
246
+ warnings.push(`estimated_tokens (${parsed.estimated_tokens}) seems very high.`);
247
+ }
248
+ if (parsed.estimated_cost_usd < 0.01) {
249
+ warnings.push(
250
+ `estimated_cost_usd ($${parsed.estimated_cost_usd}) seems very low.`
251
+ );
252
+ }
253
+ if (parsed.estimated_time_minutes < 1) {
254
+ warnings.push(
255
+ `estimated_time_minutes (${parsed.estimated_time_minutes}) seems unrealistically low.`
256
+ );
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Validates a spec directory. Single standard: specmarket.yaml is required for every spec.
262
+ * Format and metadata come from the sidecar; format-specific content checks run based on spec_format.
263
+ */
264
+ export async function validateSpec(specPath: string): Promise<ValidationResult> {
265
+ const dir = resolve(specPath);
266
+ const errors: string[] = [];
267
+ const warnings: string[] = [];
268
+ let format: string | undefined;
269
+ let formatDetectedBy: 'sidecar' | 'heuristic' | undefined = 'sidecar';
270
+
271
+ // Universal: directory non-empty
272
+ try {
273
+ const entries = await readdir(dir, { withFileTypes: true });
274
+ const hasAnyFile = entries.some((e) => e.isFile());
275
+ if (!hasAnyFile) {
276
+ errors.push('Directory is empty or has no readable files');
277
+ }
290
278
  } catch {
291
- // Directory unreadable
279
+ errors.push('Directory is empty or unreadable');
292
280
  }
293
- return results;
281
+
282
+ const sidecarPath = join(dir, SIDECAR_FILENAME);
283
+ const sidecarExists = await fileExists(sidecarPath);
284
+
285
+ if (!sidecarExists) {
286
+ errors.push(`${SIDECAR_FILENAME} is required for all specs (single source of truth for format and metadata)`);
287
+ } else {
288
+ try {
289
+ const raw = await readFile(sidecarPath, 'utf-8');
290
+ const parsed = parseYaml(raw) as unknown;
291
+ const sidecarResult = specmarketSidecarSchema.safeParse(parsed);
292
+ if (!sidecarResult.success) {
293
+ for (const issue of sidecarResult.error.issues) {
294
+ errors.push(
295
+ `${SIDECAR_FILENAME}: ${issue.path.join('.')} — ${issue.message}`
296
+ );
297
+ }
298
+ } else {
299
+ const sidecar = sidecarResult.data;
300
+ format = sidecar.spec_format;
301
+
302
+ if (sidecar.estimated_tokens !== undefined) {
303
+ if (sidecar.estimated_tokens < 1000) {
304
+ warnings.push(
305
+ `estimated_tokens (${sidecar.estimated_tokens}) seems very low.`
306
+ );
307
+ }
308
+ if (sidecar.estimated_tokens > 10_000_000) {
309
+ warnings.push(`estimated_tokens (${sidecar.estimated_tokens}) seems very high.`);
310
+ }
311
+ }
312
+ if (sidecar.estimated_cost_usd !== undefined && sidecar.estimated_cost_usd < 0.01) {
313
+ warnings.push(
314
+ `estimated_cost_usd ($${sidecar.estimated_cost_usd}) seems very low.`
315
+ );
316
+ }
317
+ if (
318
+ sidecar.estimated_time_minutes !== undefined &&
319
+ sidecar.estimated_time_minutes < 1
320
+ ) {
321
+ warnings.push(
322
+ `estimated_time_minutes (${sidecar.estimated_time_minutes}) seems unrealistically low.`
323
+ );
324
+ }
325
+
326
+ // Format-specific content validation (based on sidecar.spec_format)
327
+ switch (format) {
328
+ case 'specmarket':
329
+ await validateSpecmarketContent(dir, errors, warnings);
330
+ break;
331
+ case 'speckit': {
332
+ const hasSpecMd = await fileExists(join(dir, 'spec.md'));
333
+ const hasTasksMd = await fileExists(join(dir, 'tasks.md'));
334
+ const hasPlanMd = await fileExists(join(dir, 'plan.md'));
335
+ const hasSpecifyDir = await directoryExists(join(dir, '.specify'));
336
+ if (!hasSpecMd) errors.push('speckit format requires spec.md');
337
+ if (!hasTasksMd && !hasPlanMd) errors.push('speckit format requires tasks.md or plan.md');
338
+ if (!hasSpecifyDir) warnings.push('speckit format: .specify/ directory is recommended');
339
+ break;
340
+ }
341
+ case 'bmad': {
342
+ const hasPrdMd = await fileExists(join(dir, 'prd.md'));
343
+ const hasStory = await hasStoryFiles(dir);
344
+ if (!hasPrdMd && !hasStory) errors.push('bmad format requires prd.md or story-*.md files');
345
+ const hasArch = await fileExists(join(dir, 'architecture.md'));
346
+ if (!hasArch) warnings.push('bmad format: architecture.md is recommended');
347
+ break;
348
+ }
349
+ case 'ralph': {
350
+ const prdPath = join(dir, 'prd.json');
351
+ if (!(await fileExists(prdPath))) {
352
+ errors.push('ralph format requires prd.json');
353
+ break;
354
+ }
355
+ try {
356
+ const raw = await readFile(prdPath, 'utf-8');
357
+ const data = JSON.parse(raw) as unknown;
358
+ if (
359
+ !data ||
360
+ typeof data !== 'object' ||
361
+ !('userStories' in data) ||
362
+ !Array.isArray((data as { userStories: unknown }).userStories)
363
+ ) {
364
+ errors.push('ralph format: prd.json must have userStories array');
365
+ }
366
+ } catch {
367
+ errors.push('ralph format: prd.json must be valid JSON with userStories array');
368
+ }
369
+ break;
370
+ }
371
+ case 'custom':
372
+ default: {
373
+ const hasMd = await hasMarkdownFiles(dir);
374
+ if (!hasMd) {
375
+ errors.push('custom format requires at least one .md file');
376
+ break;
377
+ }
378
+ const textExtensions = new Set(['.md']);
379
+ const mdFiles = await collectFiles(dir, dir, textExtensions);
380
+ let hasSubstantialMd = false;
381
+ for (const f of mdFiles) {
382
+ try {
383
+ const content = await readFile(join(dir, f), 'utf-8');
384
+ if (content.length > 100) {
385
+ hasSubstantialMd = true;
386
+ break;
387
+ }
388
+ } catch {
389
+ // skip
390
+ }
391
+ }
392
+ if (!hasSubstantialMd) {
393
+ errors.push('custom format requires at least one .md file larger than 100 bytes');
394
+ }
395
+ break;
396
+ }
397
+ }
398
+ }
399
+ } catch (err) {
400
+ errors.push(
401
+ `${SIDECAR_FILENAME}: Failed to read or parse: ${(err as Error).message}`
402
+ );
403
+ }
404
+ }
405
+
406
+ return {
407
+ valid: errors.length === 0,
408
+ errors,
409
+ warnings,
410
+ format,
411
+ formatDetectedBy,
412
+ };
294
413
  }
295
414
 
296
415
  export function createValidateCommand(): Command {
297
416
  return new Command('validate')
298
417
  .description('Validate a spec directory for completeness and schema compliance')
299
- .argument('<path>', 'Path to the spec directory')
418
+ .argument('[path]', 'Path to the spec directory (defaults to current directory)', '.')
300
419
  .action(async (specPath: string) => {
301
420
  try {
302
421
  const result = await validateSpec(specPath);
303
422
 
423
+ if (result.format !== undefined) {
424
+ console.log(chalk.gray(`Format: ${result.format}`));
425
+ }
426
+
304
427
  if (result.warnings.length > 0) {
305
428
  console.log(chalk.yellow('\nWarnings:'));
306
429
  for (const warning of result.warnings) {
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // --- Hoisted mocks ---
4
+
5
+ const { mockQuery, mockClient, mockLoadCreds } = vi.hoisted(() => {
6
+ const mockQuery = vi.fn();
7
+ const mockClient = { query: mockQuery };
8
+ const mockLoadCreds = vi.fn();
9
+ return { mockQuery, mockClient, mockLoadCreds };
10
+ });
11
+
12
+ vi.mock('../lib/convex-client.js', () => ({
13
+ getConvexClient: vi.fn().mockResolvedValue(mockClient),
14
+ }));
15
+
16
+ vi.mock('../lib/auth.js', () => ({
17
+ loadCredentials: mockLoadCreds,
18
+ }));
19
+
20
+ vi.mock('@specmarket/convex/api', () => ({
21
+ api: {
22
+ users: { getMe: 'users.getMe' },
23
+ },
24
+ }));
25
+
26
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
27
+
28
+ import { handleWhoami } from './whoami.js';
29
+
30
+ describe('handleWhoami', () => {
31
+ beforeEach(() => {
32
+ vi.clearAllMocks();
33
+ });
34
+
35
+ it('shows not-logged-in message when no credentials exist', async () => {
36
+ mockLoadCreds.mockResolvedValue(null);
37
+
38
+ await handleWhoami();
39
+
40
+ expect(consoleSpy).toHaveBeenCalledWith(
41
+ expect.stringContaining('Not logged in')
42
+ );
43
+ });
44
+
45
+ it('displays full profile from Convex when available', async () => {
46
+ mockLoadCreds.mockResolvedValue({
47
+ token: 'test-token',
48
+ username: 'alice',
49
+ expiresAt: Date.now() + 30 * 24 * 3600_000,
50
+ });
51
+ mockQuery.mockResolvedValue({
52
+ username: 'alice',
53
+ displayName: 'Alice Smith',
54
+ role: 'user',
55
+ totalSpecsPublished: 5,
56
+ reputationScore: 120,
57
+ });
58
+
59
+ await handleWhoami();
60
+
61
+ expect(consoleSpy).toHaveBeenCalledWith(
62
+ expect.stringContaining('Authenticated as')
63
+ );
64
+ expect(consoleSpy).toHaveBeenCalledWith(
65
+ expect.stringContaining('@alice')
66
+ );
67
+ expect(consoleSpy).toHaveBeenCalledWith(
68
+ expect.stringContaining('Alice Smith')
69
+ );
70
+ expect(consoleSpy).toHaveBeenCalledWith(
71
+ expect.stringContaining('120')
72
+ );
73
+ });
74
+
75
+ it('falls back to cached credentials when Convex is unreachable', async () => {
76
+ mockLoadCreds.mockResolvedValue({
77
+ token: 'test-token',
78
+ username: 'bob',
79
+ expiresAt: Date.now() + 30 * 24 * 3600_000,
80
+ });
81
+ mockQuery.mockRejectedValue(new Error('Network error'));
82
+
83
+ await handleWhoami();
84
+
85
+ expect(consoleSpy).toHaveBeenCalledWith(
86
+ expect.stringContaining('Authenticated (cached)')
87
+ );
88
+ expect(consoleSpy).toHaveBeenCalledWith(
89
+ expect.stringContaining('@bob')
90
+ );
91
+ });
92
+
93
+ it('shows "unknown" when cached credentials have no username', async () => {
94
+ mockLoadCreds.mockResolvedValue({
95
+ token: 'test-token',
96
+ expiresAt: Date.now() + 3600_000,
97
+ });
98
+ mockQuery.mockRejectedValue(new Error('Network error'));
99
+
100
+ await handleWhoami();
101
+
102
+ expect(consoleSpy).toHaveBeenCalledWith(
103
+ expect.stringContaining('@unknown')
104
+ );
105
+ });
106
+ });