@specmarket/cli 0.0.3 → 0.0.5
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/CHANGELOG.md +6 -1
- package/README.md +1 -1
- package/dist/api-GIDUNUXG.js +0 -0
- package/dist/{chunk-MS2DYACY.js → chunk-DLEMNRTH.js} +19 -2
- package/dist/chunk-DLEMNRTH.js.map +1 -0
- package/dist/chunk-JEUDDJP7.js +0 -0
- package/dist/{config-R5KWZSJP.js → config-OAU6SJLC.js} +2 -2
- package/dist/exec-K3BOXX3C.js +0 -0
- package/dist/index.js +980 -181
- package/dist/index.js.map +1 -1
- package/package.json +21 -15
- package/src/commands/comment.test.ts +211 -0
- package/src/commands/comment.ts +176 -0
- package/src/commands/fork.test.ts +163 -0
- package/src/commands/info.test.ts +192 -0
- package/src/commands/info.ts +66 -2
- package/src/commands/init.test.ts +106 -0
- package/src/commands/init.ts +12 -10
- package/src/commands/issues.test.ts +377 -0
- package/src/commands/issues.ts +443 -0
- package/src/commands/login.test.ts +99 -0
- package/src/commands/logout.test.ts +54 -0
- package/src/commands/publish.test.ts +146 -0
- package/src/commands/report.test.ts +181 -0
- package/src/commands/run.test.ts +213 -0
- package/src/commands/run.ts +10 -2
- package/src/commands/search.test.ts +147 -0
- package/src/commands/validate.test.ts +129 -2
- package/src/commands/validate.ts +333 -192
- package/src/commands/whoami.test.ts +106 -0
- package/src/index.ts +6 -0
- package/src/lib/convex-client.ts +6 -2
- package/src/lib/format-detection.test.ts +223 -0
- package/src/lib/format-detection.ts +172 -0
- package/src/lib/ralph-loop.ts +49 -20
- package/src/lib/telemetry.ts +2 -1
- package/dist/chunk-MS2DYACY.js.map +0 -1
- /package/dist/{config-R5KWZSJP.js.map → config-OAU6SJLC.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -6,12 +6,14 @@ import {
|
|
|
6
6
|
REQUIRED_SPEC_FILES,
|
|
7
7
|
REQUIRED_STDLIB_FILES,
|
|
8
8
|
RUN_DEFAULTS,
|
|
9
|
+
SIDECAR_FILENAME,
|
|
9
10
|
TOKEN_EXPIRY_MS,
|
|
10
11
|
loadConfig,
|
|
11
12
|
saveConfig,
|
|
12
13
|
specYamlSchema,
|
|
14
|
+
specmarketSidecarSchema,
|
|
13
15
|
transformInfrastructure
|
|
14
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-DLEMNRTH.js";
|
|
15
17
|
import {
|
|
16
18
|
api
|
|
17
19
|
} from "./chunk-JEUDDJP7.js";
|
|
@@ -97,6 +99,11 @@ var debug2 = createDebug2("specmarket:convex");
|
|
|
97
99
|
async function getConvexClient(token) {
|
|
98
100
|
const config = await loadConfig();
|
|
99
101
|
const url = process.env["CONVEX_URL"] ?? config.convexUrl ?? DEFAULT_CONVEX_URL;
|
|
102
|
+
if (url.includes("placeholder.convex.cloud")) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
"CONVEX_URL is not configured. Set the CONVEX_URL environment variable or run `specmarket config set convexUrl <url>`."
|
|
105
|
+
);
|
|
106
|
+
}
|
|
100
107
|
debug2("Creating Convex client for URL: %s", url);
|
|
101
108
|
const client = new ConvexHttpClient(url);
|
|
102
109
|
if (token) {
|
|
@@ -162,7 +169,7 @@ async function handleTokenLogin(token) {
|
|
|
162
169
|
}
|
|
163
170
|
}
|
|
164
171
|
async function handleDeviceCodeLogin() {
|
|
165
|
-
const config = await import("./config-
|
|
172
|
+
const config = await import("./config-OAU6SJLC.js").then((m) => m.loadConfig());
|
|
166
173
|
const baseUrl = config.convexUrl ?? process.env["CONVEX_URL"] ?? "https://your-deployment.convex.cloud";
|
|
167
174
|
const webUrl = baseUrl.replace("convex.cloud", "specmarket.dev");
|
|
168
175
|
const client = await getConvexClient();
|
|
@@ -355,7 +362,7 @@ ${data.replacesSaas ? `replaces_saas: "${data.replacesSaas}"` : '# replaces_saas
|
|
|
355
362
|
output_type: ${data.outputType}
|
|
356
363
|
primary_stack: ${data.primaryStack}
|
|
357
364
|
version: "1.0.0"
|
|
358
|
-
runner: claude
|
|
365
|
+
runner: claude
|
|
359
366
|
min_model: "claude-opus-4-5"
|
|
360
367
|
|
|
361
368
|
estimated_tokens: 50000
|
|
@@ -399,9 +406,9 @@ Read the requirements in SPEC.md and implement the application step by step.
|
|
|
399
406
|
## Process
|
|
400
407
|
|
|
401
408
|
1. Read SPEC.md completely before writing any code
|
|
402
|
-
2. Check
|
|
409
|
+
2. Check TASKS.md for outstanding items
|
|
403
410
|
3. Implement features, run tests, iterate
|
|
404
|
-
4. Update
|
|
411
|
+
4. Update TASKS.md as you complete items
|
|
405
412
|
5. Verify SUCCESS_CRITERIA.md criteria are met
|
|
406
413
|
|
|
407
414
|
## Rules
|
|
@@ -409,7 +416,7 @@ Read the requirements in SPEC.md and implement the application step by step.
|
|
|
409
416
|
- Follow stdlib/STACK.md for technology choices
|
|
410
417
|
- Write tests for all business logic
|
|
411
418
|
- Do not skip steps or take shortcuts
|
|
412
|
-
- Update
|
|
419
|
+
- Update TASKS.md after each significant change
|
|
413
420
|
`;
|
|
414
421
|
var SPEC_MD_TEMPLATE = (data) => `# ${data.displayName} \u2014 Specification
|
|
415
422
|
|
|
@@ -468,12 +475,12 @@ ${primaryStack}
|
|
|
468
475
|
- Vitest for unit tests
|
|
469
476
|
- Playwright for E2E (optional)
|
|
470
477
|
`;
|
|
471
|
-
var
|
|
478
|
+
var TASKS_MD_TEMPLATE = (displayName) => `# Tasks
|
|
472
479
|
|
|
473
480
|
> This file tracks outstanding work. Update it after each change.
|
|
474
|
-
>
|
|
481
|
+
> All items checked = implementation complete.
|
|
475
482
|
|
|
476
|
-
## ${displayName} \u2014 Initial Implementation
|
|
483
|
+
## Phase 1: ${displayName} \u2014 Initial Implementation
|
|
477
484
|
|
|
478
485
|
- [ ] Set up project structure and dependencies
|
|
479
486
|
- [ ] Implement core data model
|
|
@@ -482,6 +489,8 @@ var FIX_PLAN_TEMPLATE = (displayName) => `# Fix Plan
|
|
|
482
489
|
- [ ] Implement UI/interface
|
|
483
490
|
- [ ] Write integration tests
|
|
484
491
|
- [ ] Update README.md
|
|
492
|
+
|
|
493
|
+
## Discovered Issues
|
|
485
494
|
`;
|
|
486
495
|
async function handleInit(opts) {
|
|
487
496
|
const { default: inquirer } = await import("inquirer");
|
|
@@ -549,7 +558,7 @@ async function handleInit(opts) {
|
|
|
549
558
|
writeFile2(join2(targetDir, "SPEC.md"), SPEC_MD_TEMPLATE(data)),
|
|
550
559
|
writeFile2(join2(targetDir, "SUCCESS_CRITERIA.md"), SUCCESS_CRITERIA_TEMPLATE),
|
|
551
560
|
writeFile2(join2(targetDir, "stdlib", "STACK.md"), STACK_MD_TEMPLATE(answers.primaryStack)),
|
|
552
|
-
writeFile2(join2(targetDir, "
|
|
561
|
+
writeFile2(join2(targetDir, "TASKS.md"), TASKS_MD_TEMPLATE(answers.displayName))
|
|
553
562
|
]);
|
|
554
563
|
spinner.succeed(chalk4.green(`Spec created at ${targetDir}`));
|
|
555
564
|
console.log("");
|
|
@@ -557,8 +566,8 @@ async function handleInit(opts) {
|
|
|
557
566
|
console.log(` 1. ${chalk4.cyan(`cd ${answers.name}`)}`);
|
|
558
567
|
console.log(` 2. Edit ${chalk4.cyan("SPEC.md")} with your application requirements`);
|
|
559
568
|
console.log(` 3. Edit ${chalk4.cyan("SUCCESS_CRITERIA.md")} with specific pass/fail criteria`);
|
|
560
|
-
console.log(` 4. Run ${chalk4.cyan(`specmarket validate
|
|
561
|
-
console.log(` 5. Run ${chalk4.cyan(`specmarket run
|
|
569
|
+
console.log(` 4. Run ${chalk4.cyan(`specmarket validate`)} to check your spec`);
|
|
570
|
+
console.log(` 5. Run ${chalk4.cyan(`specmarket run`)} to execute the spec`);
|
|
562
571
|
} catch (err) {
|
|
563
572
|
spinner.fail(chalk4.red(`Failed to create spec: ${err.message}`));
|
|
564
573
|
throw err;
|
|
@@ -578,123 +587,173 @@ function createInitCommand() {
|
|
|
578
587
|
// src/commands/validate.ts
|
|
579
588
|
import { Command as Command5 } from "commander";
|
|
580
589
|
import chalk5 from "chalk";
|
|
590
|
+
import { readFile as readFile3, readdir as readdir2, access as access2 } from "fs/promises";
|
|
591
|
+
import { join as join4, resolve as resolve2, relative, normalize } from "path";
|
|
592
|
+
import { parse as parseYaml2 } from "yaml";
|
|
593
|
+
|
|
594
|
+
// src/lib/format-detection.ts
|
|
581
595
|
import { readFile as readFile2, readdir, access } from "fs/promises";
|
|
582
|
-
import { join as join3
|
|
596
|
+
import { join as join3 } from "path";
|
|
583
597
|
import { parse as parseYaml } from "yaml";
|
|
584
|
-
async function
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
try {
|
|
591
|
-
await access(filePath);
|
|
592
|
-
const content = await readFile2(filePath, "utf-8");
|
|
593
|
-
if (content.trim().length === 0) {
|
|
594
|
-
errors.push(`${file} exists but is empty`);
|
|
595
|
-
}
|
|
596
|
-
} catch {
|
|
597
|
-
errors.push(`Required file missing: ${file}`);
|
|
598
|
-
}
|
|
598
|
+
async function fileExists(filePath) {
|
|
599
|
+
try {
|
|
600
|
+
await access(filePath);
|
|
601
|
+
return true;
|
|
602
|
+
} catch {
|
|
603
|
+
return false;
|
|
599
604
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
605
|
+
}
|
|
606
|
+
async function directoryExists(dirPath) {
|
|
607
|
+
try {
|
|
608
|
+
await access(dirPath);
|
|
609
|
+
return true;
|
|
610
|
+
} catch {
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
async function hasStoryFiles(dir) {
|
|
615
|
+
try {
|
|
616
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
617
|
+
return entries.some(
|
|
618
|
+
(e) => e.isFile() && e.name.startsWith("story-") && e.name.endsWith(".md")
|
|
619
|
+
);
|
|
620
|
+
} catch {
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async function hasMarkdownFiles(dir) {
|
|
625
|
+
try {
|
|
626
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
627
|
+
for (const entry of entries) {
|
|
628
|
+
if (entry.isFile() && entry.name.endsWith(".md")) return true;
|
|
629
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
|
|
630
|
+
const found = await hasMarkdownFiles(join3(dir, entry.name));
|
|
631
|
+
if (found) return true;
|
|
608
632
|
}
|
|
609
|
-
} catch {
|
|
610
|
-
errors.push(`Required file missing: stdlib/${file}`);
|
|
611
633
|
}
|
|
634
|
+
return false;
|
|
635
|
+
} catch {
|
|
636
|
+
return false;
|
|
612
637
|
}
|
|
613
|
-
|
|
614
|
-
|
|
638
|
+
}
|
|
639
|
+
async function tryReadSidecar(dir) {
|
|
640
|
+
const path = join3(dir, SIDECAR_FILENAME);
|
|
641
|
+
if (!await fileExists(path)) return null;
|
|
615
642
|
try {
|
|
616
|
-
const raw = await readFile2(
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
}
|
|
622
|
-
const parseResult = specYamlSchema.safeParse(specYaml);
|
|
623
|
-
if (!parseResult.success) {
|
|
624
|
-
for (const issue of parseResult.error.issues) {
|
|
625
|
-
errors.push(
|
|
626
|
-
`spec.yaml: ${issue.path.join(".")} \u2014 ${issue.message}`
|
|
627
|
-
);
|
|
643
|
+
const raw = await readFile2(path, "utf-8");
|
|
644
|
+
const parsed = parseYaml(raw);
|
|
645
|
+
if (parsed && typeof parsed === "object" && "spec_format" in parsed) {
|
|
646
|
+
const fmt = parsed.spec_format;
|
|
647
|
+
if (typeof fmt === "string" && fmt.length > 0) return { spec_format: fmt };
|
|
628
648
|
}
|
|
629
|
-
|
|
630
|
-
|
|
649
|
+
return null;
|
|
650
|
+
} catch {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
async function detectSpecFormat(dir) {
|
|
655
|
+
const sidecar = await tryReadSidecar(dir);
|
|
656
|
+
if (sidecar) {
|
|
657
|
+
return {
|
|
658
|
+
format: sidecar.spec_format,
|
|
659
|
+
detectedBy: "sidecar",
|
|
660
|
+
confidence: "high"
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
const hasSpecYaml = await fileExists(join3(dir, "spec.yaml"));
|
|
664
|
+
const hasPromptMd = await fileExists(join3(dir, "PROMPT.md"));
|
|
665
|
+
const hasSuccessCriteria = await fileExists(join3(dir, "SUCCESS_CRITERIA.md"));
|
|
666
|
+
if (hasSpecYaml && hasPromptMd && hasSuccessCriteria) {
|
|
667
|
+
return {
|
|
668
|
+
format: "specmarket-legacy",
|
|
669
|
+
detectedBy: "heuristic",
|
|
670
|
+
confidence: "high"
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
if (hasPromptMd && hasSuccessCriteria) {
|
|
674
|
+
return {
|
|
675
|
+
format: "specmarket-legacy",
|
|
676
|
+
detectedBy: "heuristic",
|
|
677
|
+
confidence: "high"
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
const hasSpecMd = await fileExists(join3(dir, "spec.md"));
|
|
681
|
+
const hasPlanMd = await fileExists(join3(dir, "plan.md"));
|
|
682
|
+
const hasTasksMd = await fileExists(join3(dir, "tasks.md"));
|
|
683
|
+
if (hasSpecMd && (hasPlanMd || hasTasksMd)) {
|
|
684
|
+
return {
|
|
685
|
+
format: "speckit",
|
|
686
|
+
detectedBy: "heuristic",
|
|
687
|
+
confidence: "high"
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
if (hasSpecMd) {
|
|
691
|
+
return {
|
|
692
|
+
format: "speckit",
|
|
693
|
+
detectedBy: "heuristic",
|
|
694
|
+
confidence: "high"
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
const hasPrdMd = await fileExists(join3(dir, "prd.md"));
|
|
698
|
+
const hasArchitectureMd = await fileExists(join3(dir, "architecture.md"));
|
|
699
|
+
const storyFiles = await hasStoryFiles(dir);
|
|
700
|
+
if (hasPrdMd && (hasArchitectureMd || storyFiles)) {
|
|
701
|
+
return {
|
|
702
|
+
format: "bmad",
|
|
703
|
+
detectedBy: "heuristic",
|
|
704
|
+
confidence: "high"
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
const prdJsonPath = join3(dir, "prd.json");
|
|
708
|
+
if (await fileExists(prdJsonPath)) {
|
|
631
709
|
try {
|
|
632
|
-
const
|
|
633
|
-
const
|
|
634
|
-
if (
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
710
|
+
const raw = await readFile2(prdJsonPath, "utf-8");
|
|
711
|
+
const data = JSON.parse(raw);
|
|
712
|
+
if (data && typeof data === "object") {
|
|
713
|
+
return {
|
|
714
|
+
format: "ralph",
|
|
715
|
+
detectedBy: "heuristic",
|
|
716
|
+
confidence: "high"
|
|
717
|
+
};
|
|
638
718
|
}
|
|
639
719
|
} catch {
|
|
640
720
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
721
|
+
}
|
|
722
|
+
if (await hasMarkdownFiles(dir)) {
|
|
723
|
+
return {
|
|
724
|
+
format: "custom",
|
|
725
|
+
detectedBy: "heuristic",
|
|
726
|
+
confidence: "low"
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
return {
|
|
730
|
+
format: "custom",
|
|
731
|
+
detectedBy: "heuristic",
|
|
732
|
+
confidence: "low"
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// src/commands/validate.ts
|
|
737
|
+
async function collectFiles(currentDir, baseDir, extensions) {
|
|
738
|
+
const results = [];
|
|
739
|
+
try {
|
|
740
|
+
const entries = await readdir2(currentDir, { withFileTypes: true });
|
|
741
|
+
for (const entry of entries) {
|
|
742
|
+
const fullPath = join4(currentDir, entry.name);
|
|
743
|
+
if (entry.isDirectory()) {
|
|
744
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
745
|
+
const subFiles = await collectFiles(fullPath, baseDir, extensions);
|
|
746
|
+
results.push(...subFiles);
|
|
747
|
+
} else if (entry.isFile()) {
|
|
748
|
+
const ext = entry.name.includes(".") ? "." + entry.name.split(".").pop() : "";
|
|
749
|
+
if (extensions.has(ext)) {
|
|
750
|
+
results.push(relative(baseDir, fullPath));
|
|
663
751
|
}
|
|
664
752
|
}
|
|
665
|
-
} else {
|
|
666
|
-
if (["web-app", "api-service", "mobile-app"].includes(parsed.output_type)) {
|
|
667
|
-
warnings.push(
|
|
668
|
-
"No infrastructure block defined. Consider adding infrastructure.services for database, auth, etc."
|
|
669
|
-
);
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
if (parsed.estimated_tokens < 1e3) {
|
|
673
|
-
warnings.push(
|
|
674
|
-
`estimated_tokens (${parsed.estimated_tokens}) seems very low. Most specs use 10,000+.`
|
|
675
|
-
);
|
|
676
|
-
}
|
|
677
|
-
if (parsed.estimated_tokens > 1e7) {
|
|
678
|
-
warnings.push(
|
|
679
|
-
`estimated_tokens (${parsed.estimated_tokens}) seems very high.`
|
|
680
|
-
);
|
|
681
|
-
}
|
|
682
|
-
if (parsed.estimated_cost_usd < 0.01) {
|
|
683
|
-
warnings.push(
|
|
684
|
-
`estimated_cost_usd ($${parsed.estimated_cost_usd}) seems very low.`
|
|
685
|
-
);
|
|
686
|
-
}
|
|
687
|
-
if (parsed.estimated_time_minutes < 1) {
|
|
688
|
-
warnings.push(
|
|
689
|
-
`estimated_time_minutes (${parsed.estimated_time_minutes}) seems unrealistically low.`
|
|
690
|
-
);
|
|
691
753
|
}
|
|
754
|
+
} catch {
|
|
692
755
|
}
|
|
693
|
-
return
|
|
694
|
-
valid: errors.length === 0,
|
|
695
|
-
errors,
|
|
696
|
-
warnings
|
|
697
|
-
};
|
|
756
|
+
return results;
|
|
698
757
|
}
|
|
699
758
|
async function detectCircularReferences(dir) {
|
|
700
759
|
const textExtensions = /* @__PURE__ */ new Set([".md", ".yaml", ".yml"]);
|
|
@@ -704,7 +763,7 @@ async function detectCircularReferences(dir) {
|
|
|
704
763
|
for (const file of files) {
|
|
705
764
|
const refs = /* @__PURE__ */ new Set();
|
|
706
765
|
try {
|
|
707
|
-
const content = await
|
|
766
|
+
const content = await readFile3(join4(dir, file), "utf-8");
|
|
708
767
|
let match;
|
|
709
768
|
while ((match = linkPattern.exec(content)) !== null) {
|
|
710
769
|
const target = match[1];
|
|
@@ -713,7 +772,7 @@ async function detectCircularReferences(dir) {
|
|
|
713
772
|
}
|
|
714
773
|
const targetPath = target.split("#")[0];
|
|
715
774
|
if (!targetPath) continue;
|
|
716
|
-
const fileDir =
|
|
775
|
+
const fileDir = join4(dir, file, "..");
|
|
717
776
|
const resolvedTarget = normalize(relative(dir, resolve2(fileDir, targetPath)));
|
|
718
777
|
if (!resolvedTarget.startsWith("..") && files.includes(resolvedTarget)) {
|
|
719
778
|
refs.add(resolvedTarget);
|
|
@@ -760,31 +819,259 @@ async function detectCircularReferences(dir) {
|
|
|
760
819
|
}
|
|
761
820
|
return cycles;
|
|
762
821
|
}
|
|
763
|
-
async function
|
|
764
|
-
const
|
|
822
|
+
async function validateLegacySpec(dir, errors, warnings) {
|
|
823
|
+
for (const file of REQUIRED_SPEC_FILES) {
|
|
824
|
+
const filePath = join4(dir, file);
|
|
825
|
+
try {
|
|
826
|
+
await access2(filePath);
|
|
827
|
+
const content = await readFile3(filePath, "utf-8");
|
|
828
|
+
if (content.trim().length === 0) {
|
|
829
|
+
errors.push(`${file} exists but is empty`);
|
|
830
|
+
}
|
|
831
|
+
} catch {
|
|
832
|
+
errors.push(`Required file missing: ${file}`);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
const stdlibDir = join4(dir, "stdlib");
|
|
836
|
+
for (const file of REQUIRED_STDLIB_FILES) {
|
|
837
|
+
const filePath = join4(stdlibDir, file);
|
|
838
|
+
try {
|
|
839
|
+
await access2(filePath);
|
|
840
|
+
const content = await readFile3(filePath, "utf-8");
|
|
841
|
+
if (content.trim().length === 0) {
|
|
842
|
+
errors.push(`stdlib/${file} exists but is empty`);
|
|
843
|
+
}
|
|
844
|
+
} catch {
|
|
845
|
+
errors.push(`Required file missing: stdlib/${file}`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
let specYaml = null;
|
|
849
|
+
const specYamlPath = join4(dir, "spec.yaml");
|
|
765
850
|
try {
|
|
766
|
-
const
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
851
|
+
const raw = await readFile3(specYamlPath, "utf-8");
|
|
852
|
+
specYaml = parseYaml2(raw);
|
|
853
|
+
} catch (err) {
|
|
854
|
+
errors.push(`spec.yaml: Failed to parse YAML: ${err.message}`);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const parseResult = specYamlSchema.safeParse(specYaml);
|
|
858
|
+
if (!parseResult.success) {
|
|
859
|
+
for (const issue of parseResult.error.issues) {
|
|
860
|
+
errors.push(`spec.yaml: ${issue.path.join(".")} \u2014 ${issue.message}`);
|
|
861
|
+
}
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const parsed = parseResult.data;
|
|
865
|
+
try {
|
|
866
|
+
const criteriaContent = await readFile3(join4(dir, "SUCCESS_CRITERIA.md"), "utf-8");
|
|
867
|
+
const hasCriterion = /^- \[[ x]\]/m.test(criteriaContent);
|
|
868
|
+
if (!hasCriterion) {
|
|
869
|
+
errors.push(
|
|
870
|
+
"SUCCESS_CRITERIA.md: Must contain at least one criterion in format: - [ ] criterion text"
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
} catch {
|
|
874
|
+
}
|
|
875
|
+
const cycles = await detectCircularReferences(dir);
|
|
876
|
+
for (const cycle of cycles) {
|
|
877
|
+
errors.push(`Circular reference detected: ${cycle}`);
|
|
878
|
+
}
|
|
879
|
+
if (parsed.infrastructure) {
|
|
880
|
+
const infra = parsed.infrastructure;
|
|
881
|
+
if (["web-app", "api-service", "mobile-app"].includes(parsed.output_type) && infra.services.length === 0) {
|
|
882
|
+
warnings.push(
|
|
883
|
+
`${parsed.output_type} should typically define infrastructure services (database, auth, etc.)`
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
if (!infra.setup_time_minutes) {
|
|
887
|
+
warnings.push("infrastructure.setup_time_minutes is not set");
|
|
888
|
+
}
|
|
889
|
+
for (const service of infra.services) {
|
|
890
|
+
if (service.default_provider) {
|
|
891
|
+
const providerNames = service.providers.map((p) => p.name);
|
|
892
|
+
if (!providerNames.includes(service.default_provider)) {
|
|
893
|
+
errors.push(
|
|
894
|
+
`infrastructure.services[${service.name}].default_provider "${service.default_provider}" does not match any defined provider (${providerNames.join(", ")})`
|
|
895
|
+
);
|
|
777
896
|
}
|
|
778
897
|
}
|
|
779
898
|
}
|
|
899
|
+
} else {
|
|
900
|
+
if (["web-app", "api-service", "mobile-app"].includes(parsed.output_type)) {
|
|
901
|
+
warnings.push(
|
|
902
|
+
"No infrastructure block defined. Consider adding infrastructure.services for database, auth, etc."
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
if (parsed.estimated_tokens < 1e3) {
|
|
907
|
+
warnings.push(
|
|
908
|
+
`estimated_tokens (${parsed.estimated_tokens}) seems very low. Most specs use 10,000+.`
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
if (parsed.estimated_tokens > 1e7) {
|
|
912
|
+
warnings.push(`estimated_tokens (${parsed.estimated_tokens}) seems very high.`);
|
|
913
|
+
}
|
|
914
|
+
if (parsed.estimated_cost_usd < 0.01) {
|
|
915
|
+
warnings.push(
|
|
916
|
+
`estimated_cost_usd ($${parsed.estimated_cost_usd}) seems very low.`
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
if (parsed.estimated_time_minutes < 1) {
|
|
920
|
+
warnings.push(
|
|
921
|
+
`estimated_time_minutes (${parsed.estimated_time_minutes}) seems unrealistically low.`
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
async function validateSpec(specPath) {
|
|
926
|
+
const dir = resolve2(specPath);
|
|
927
|
+
const errors = [];
|
|
928
|
+
const warnings = [];
|
|
929
|
+
const detection = await detectSpecFormat(dir);
|
|
930
|
+
try {
|
|
931
|
+
const entries = await readdir2(dir, { withFileTypes: true });
|
|
932
|
+
const hasAnyFile = entries.some((e) => e.isFile());
|
|
933
|
+
if (!hasAnyFile) {
|
|
934
|
+
errors.push("Directory is empty or has no readable files");
|
|
935
|
+
}
|
|
780
936
|
} catch {
|
|
937
|
+
errors.push("Directory is empty or unreadable");
|
|
781
938
|
}
|
|
782
|
-
|
|
939
|
+
const sidecarPath = join4(dir, SIDECAR_FILENAME);
|
|
940
|
+
if (await fileExists(sidecarPath)) {
|
|
941
|
+
try {
|
|
942
|
+
const raw = await readFile3(sidecarPath, "utf-8");
|
|
943
|
+
const parsed = parseYaml2(raw);
|
|
944
|
+
const sidecarResult = specmarketSidecarSchema.safeParse(parsed);
|
|
945
|
+
if (!sidecarResult.success) {
|
|
946
|
+
for (const issue of sidecarResult.error.issues) {
|
|
947
|
+
errors.push(
|
|
948
|
+
`${SIDECAR_FILENAME}: ${issue.path.join(".")} \u2014 ${issue.message}`
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
} else {
|
|
952
|
+
const sidecar = sidecarResult.data;
|
|
953
|
+
if (sidecar.estimated_tokens !== void 0) {
|
|
954
|
+
if (sidecar.estimated_tokens < 1e3) {
|
|
955
|
+
warnings.push(
|
|
956
|
+
`sidecar estimated_tokens (${sidecar.estimated_tokens}) seems very low.`
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
if (sidecar.estimated_tokens > 1e7) {
|
|
960
|
+
warnings.push(
|
|
961
|
+
`sidecar estimated_tokens (${sidecar.estimated_tokens}) seems very high.`
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (sidecar.estimated_cost_usd !== void 0 && sidecar.estimated_cost_usd < 0.01) {
|
|
966
|
+
warnings.push(
|
|
967
|
+
`sidecar estimated_cost_usd ($${sidecar.estimated_cost_usd}) seems very low.`
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
if (sidecar.estimated_time_minutes !== void 0 && sidecar.estimated_time_minutes < 1) {
|
|
971
|
+
warnings.push(
|
|
972
|
+
`sidecar estimated_time_minutes (${sidecar.estimated_time_minutes}) seems unrealistically low.`
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
} catch (err) {
|
|
977
|
+
errors.push(
|
|
978
|
+
`${SIDECAR_FILENAME}: Failed to read or parse: ${err.message}`
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
const hasSpecYaml = await fileExists(join4(dir, "spec.yaml"));
|
|
983
|
+
if (hasSpecYaml || detection.format === "specmarket-legacy") {
|
|
984
|
+
await validateLegacySpec(dir, errors, warnings);
|
|
985
|
+
}
|
|
986
|
+
switch (detection.format) {
|
|
987
|
+
case "specmarket-legacy":
|
|
988
|
+
break;
|
|
989
|
+
case "speckit": {
|
|
990
|
+
const hasSpecMd = await fileExists(join4(dir, "spec.md"));
|
|
991
|
+
const hasTasksMd = await fileExists(join4(dir, "tasks.md"));
|
|
992
|
+
const hasPlanMd = await fileExists(join4(dir, "plan.md"));
|
|
993
|
+
const hasSpecifyDir = await directoryExists(join4(dir, ".specify"));
|
|
994
|
+
if (!hasSpecMd) {
|
|
995
|
+
errors.push("speckit format requires spec.md");
|
|
996
|
+
}
|
|
997
|
+
if (!hasTasksMd && !hasPlanMd) {
|
|
998
|
+
errors.push("speckit format requires tasks.md or plan.md");
|
|
999
|
+
}
|
|
1000
|
+
if (!hasSpecifyDir) {
|
|
1001
|
+
warnings.push("speckit format: .specify/ directory is recommended");
|
|
1002
|
+
}
|
|
1003
|
+
break;
|
|
1004
|
+
}
|
|
1005
|
+
case "bmad": {
|
|
1006
|
+
const hasPrdMd = await fileExists(join4(dir, "prd.md"));
|
|
1007
|
+
const hasStory = await hasStoryFiles(dir);
|
|
1008
|
+
if (!hasPrdMd && !hasStory) {
|
|
1009
|
+
errors.push("bmad format requires prd.md or story-*.md files");
|
|
1010
|
+
}
|
|
1011
|
+
const hasArch = await fileExists(join4(dir, "architecture.md"));
|
|
1012
|
+
if (!hasArch) {
|
|
1013
|
+
warnings.push("bmad format: architecture.md is recommended");
|
|
1014
|
+
}
|
|
1015
|
+
break;
|
|
1016
|
+
}
|
|
1017
|
+
case "ralph": {
|
|
1018
|
+
const prdPath = join4(dir, "prd.json");
|
|
1019
|
+
if (!await fileExists(prdPath)) {
|
|
1020
|
+
errors.push("ralph format requires prd.json");
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
try {
|
|
1024
|
+
const raw = await readFile3(prdPath, "utf-8");
|
|
1025
|
+
const data = JSON.parse(raw);
|
|
1026
|
+
if (!data || typeof data !== "object" || !("userStories" in data) || !Array.isArray(data.userStories)) {
|
|
1027
|
+
errors.push("ralph format: prd.json must have userStories array");
|
|
1028
|
+
}
|
|
1029
|
+
} catch {
|
|
1030
|
+
errors.push("ralph format: prd.json must be valid JSON with userStories array");
|
|
1031
|
+
}
|
|
1032
|
+
break;
|
|
1033
|
+
}
|
|
1034
|
+
case "custom":
|
|
1035
|
+
default: {
|
|
1036
|
+
const hasMd = await hasMarkdownFiles(dir);
|
|
1037
|
+
if (!hasMd) {
|
|
1038
|
+
errors.push("custom format requires at least one .md file");
|
|
1039
|
+
break;
|
|
1040
|
+
}
|
|
1041
|
+
const textExtensions = /* @__PURE__ */ new Set([".md"]);
|
|
1042
|
+
const mdFiles = await collectFiles(dir, dir, textExtensions);
|
|
1043
|
+
let hasSubstantialMd = false;
|
|
1044
|
+
for (const f of mdFiles) {
|
|
1045
|
+
try {
|
|
1046
|
+
const content = await readFile3(join4(dir, f), "utf-8");
|
|
1047
|
+
if (content.length > 100) {
|
|
1048
|
+
hasSubstantialMd = true;
|
|
1049
|
+
break;
|
|
1050
|
+
}
|
|
1051
|
+
} catch {
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
if (!hasSubstantialMd) {
|
|
1055
|
+
errors.push("custom format requires at least one .md file larger than 100 bytes");
|
|
1056
|
+
}
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
return {
|
|
1061
|
+
valid: errors.length === 0,
|
|
1062
|
+
errors,
|
|
1063
|
+
warnings,
|
|
1064
|
+
format: detection.format,
|
|
1065
|
+
formatDetectedBy: detection.detectedBy
|
|
1066
|
+
};
|
|
783
1067
|
}
|
|
784
1068
|
function createValidateCommand() {
|
|
785
|
-
return new Command5("validate").description("Validate a spec directory for completeness and schema compliance").argument("
|
|
1069
|
+
return new Command5("validate").description("Validate a spec directory for completeness and schema compliance").argument("[path]", "Path to the spec directory (defaults to current directory)", ".").action(async (specPath) => {
|
|
786
1070
|
try {
|
|
787
1071
|
const result = await validateSpec(specPath);
|
|
1072
|
+
if (result.format !== void 0) {
|
|
1073
|
+
console.log(chalk5.gray(`Detected format: ${result.format}`));
|
|
1074
|
+
}
|
|
788
1075
|
if (result.warnings.length > 0) {
|
|
789
1076
|
console.log(chalk5.yellow("\nWarnings:"));
|
|
790
1077
|
for (const warning of result.warnings) {
|
|
@@ -819,9 +1106,9 @@ Validation failed with ${result.errors.length} error(s).`)
|
|
|
819
1106
|
import { Command as Command6 } from "commander";
|
|
820
1107
|
import chalk6 from "chalk";
|
|
821
1108
|
import ora3 from "ora";
|
|
822
|
-
import { readFile as
|
|
823
|
-
import { join as
|
|
824
|
-
import { parse as
|
|
1109
|
+
import { readFile as readFile5, mkdir as mkdir4, writeFile as writeFileFn } from "fs/promises";
|
|
1110
|
+
import { join as join6, resolve as resolve4, isAbsolute } from "path";
|
|
1111
|
+
import { parse as parseYaml3 } from "yaml";
|
|
825
1112
|
|
|
826
1113
|
// src/lib/telemetry.ts
|
|
827
1114
|
import createDebug5 from "debug";
|
|
@@ -884,15 +1171,15 @@ async function promptTelemetryOptIn() {
|
|
|
884
1171
|
default: false
|
|
885
1172
|
}
|
|
886
1173
|
]);
|
|
887
|
-
const { saveConfig: saveConfig2 } = await import("./config-
|
|
1174
|
+
const { saveConfig: saveConfig2 } = await import("./config-OAU6SJLC.js");
|
|
888
1175
|
await saveConfig2({ ...config, telemetry: optIn, telemetryPrompted: true });
|
|
889
1176
|
return optIn;
|
|
890
1177
|
}
|
|
891
1178
|
|
|
892
1179
|
// src/lib/ralph-loop.ts
|
|
893
1180
|
import { spawn } from "child_process";
|
|
894
|
-
import { mkdir as mkdir3, writeFile as writeFile3, readFile as
|
|
895
|
-
import { join as
|
|
1181
|
+
import { mkdir as mkdir3, writeFile as writeFile3, readFile as readFile4, access as access3 } from "fs/promises";
|
|
1182
|
+
import { join as join5 } from "path";
|
|
896
1183
|
import { homedir as homedir2 } from "os";
|
|
897
1184
|
import { randomUUID } from "crypto";
|
|
898
1185
|
import { exec } from "child_process";
|
|
@@ -900,12 +1187,27 @@ import { promisify } from "util";
|
|
|
900
1187
|
import createDebug6 from "debug";
|
|
901
1188
|
var debug6 = createDebug6("specmarket:runner");
|
|
902
1189
|
var execAsync = promisify(exec);
|
|
1190
|
+
async function checkClaudeCliInstalled() {
|
|
1191
|
+
try {
|
|
1192
|
+
await execAsync("which claude");
|
|
1193
|
+
} catch {
|
|
1194
|
+
throw new Error(
|
|
1195
|
+
`Claude CLI is not installed or not in your PATH.
|
|
1196
|
+
|
|
1197
|
+
Installation instructions:
|
|
1198
|
+
npm install -g @anthropic-ai/claude-code
|
|
1199
|
+
|
|
1200
|
+
Or visit: https://www.anthropic.com/claude-code
|
|
1201
|
+
`
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
903
1205
|
async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
904
1206
|
const maxLoops = opts.maxLoops ?? RUN_DEFAULTS.MAX_LOOPS;
|
|
905
1207
|
const budgetTokens = opts.maxBudgetUsd ? opts.maxBudgetUsd / specYaml.estimatedCostUsd * specYaml.estimatedTokens : specYaml.estimatedTokens * RUN_DEFAULTS.BUDGET_MULTIPLIER;
|
|
906
1208
|
const runId = opts.resumeRunId ?? randomUUID();
|
|
907
|
-
const runsBaseDir =
|
|
908
|
-
const runDir = opts.outputDir ??
|
|
1209
|
+
const runsBaseDir = join5(homedir2(), CONFIG_PATHS.RUNS_DIR);
|
|
1210
|
+
const runDir = opts.outputDir ?? join5(runsBaseDir, runId);
|
|
909
1211
|
await mkdir3(runDir, { recursive: true });
|
|
910
1212
|
debug6("Run directory: %s", runDir);
|
|
911
1213
|
if (opts.dryRun) {
|
|
@@ -967,7 +1269,7 @@ async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
|
967
1269
|
iterations.push(iteration);
|
|
968
1270
|
onProgress?.(iteration);
|
|
969
1271
|
await writeFile3(
|
|
970
|
-
|
|
1272
|
+
join5(runDir, `iteration-${i}.json`),
|
|
971
1273
|
JSON.stringify(iteration, null, 2)
|
|
972
1274
|
);
|
|
973
1275
|
await stageAllChanges(runDir);
|
|
@@ -1030,7 +1332,7 @@ async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
|
1030
1332
|
cliVersion: opts.cliVersion
|
|
1031
1333
|
};
|
|
1032
1334
|
await writeFile3(
|
|
1033
|
-
|
|
1335
|
+
join5(runDir, "run-report.json"),
|
|
1034
1336
|
JSON.stringify(report, null, 2)
|
|
1035
1337
|
);
|
|
1036
1338
|
debug6("Run complete: %s (status=%s, loops=%d)", runId, finalStatus, iterations.length);
|
|
@@ -1038,7 +1340,7 @@ async function runSpec(specDir, specYaml, opts, onProgress) {
|
|
|
1038
1340
|
}
|
|
1039
1341
|
async function copySpecFiles(srcDir, destDir) {
|
|
1040
1342
|
const { cp } = await import("fs/promises");
|
|
1041
|
-
await cp(srcDir,
|
|
1343
|
+
await cp(srcDir, join5(destDir, "spec"), { recursive: true });
|
|
1042
1344
|
await cp(srcDir, destDir, { recursive: true, force: false });
|
|
1043
1345
|
debug6("Spec files copied from %s to %s", srcDir, destDir);
|
|
1044
1346
|
}
|
|
@@ -1072,7 +1374,7 @@ async function executeClaudeLoop(dir, model) {
|
|
|
1072
1374
|
if (model) {
|
|
1073
1375
|
args.push("--model", model);
|
|
1074
1376
|
}
|
|
1075
|
-
const proc = spawn("sh", ["-c", `cat PROMPT.md | claude
|
|
1377
|
+
const proc = spawn("sh", ["-c", `cat PROMPT.md | claude ${args.join(" ")}`], {
|
|
1076
1378
|
cwd: dir,
|
|
1077
1379
|
stdio: ["inherit", "pipe", "pipe"]
|
|
1078
1380
|
});
|
|
@@ -1089,7 +1391,7 @@ async function executeClaudeLoop(dir, model) {
|
|
|
1089
1391
|
resolve7({ stdout, exitCode: code ?? 0 });
|
|
1090
1392
|
});
|
|
1091
1393
|
proc.on("error", (err) => {
|
|
1092
|
-
debug6("claude
|
|
1394
|
+
debug6("claude spawn error: %O", err);
|
|
1093
1395
|
resolve7({ stdout: "", exitCode: 1 });
|
|
1094
1396
|
});
|
|
1095
1397
|
});
|
|
@@ -1176,7 +1478,7 @@ async function checkCompletion(dir) {
|
|
|
1176
1478
|
}
|
|
1177
1479
|
async function isFixPlanEmpty(dir) {
|
|
1178
1480
|
try {
|
|
1179
|
-
const content = await
|
|
1481
|
+
const content = await readFile4(join5(dir, "TASKS.md"), "utf-8");
|
|
1180
1482
|
const hasUncheckedItems = /^- \[ \]/m.test(content);
|
|
1181
1483
|
return !hasUncheckedItems;
|
|
1182
1484
|
} catch {
|
|
@@ -1185,22 +1487,29 @@ async function isFixPlanEmpty(dir) {
|
|
|
1185
1487
|
}
|
|
1186
1488
|
async function runTests(dir) {
|
|
1187
1489
|
const testRunners = [
|
|
1188
|
-
{ file: "package.json", cmd: "npm test -- --run 2>&1
|
|
1189
|
-
{ file: "vitest.config.ts", cmd: "npx vitest run 2>&1
|
|
1190
|
-
{ file: "pytest.ini", cmd: "python -m pytest --tb=no -q 2>&1
|
|
1191
|
-
{ file: "Makefile", cmd: "make test 2>&1
|
|
1490
|
+
{ file: "package.json", cmd: "npm test -- --run 2>&1" },
|
|
1491
|
+
{ file: "vitest.config.ts", cmd: "npx vitest run 2>&1" },
|
|
1492
|
+
{ file: "pytest.ini", cmd: "python -m pytest --tb=no -q 2>&1" },
|
|
1493
|
+
{ file: "Makefile", cmd: "make test 2>&1" }
|
|
1192
1494
|
];
|
|
1193
1495
|
for (const runner of testRunners) {
|
|
1194
1496
|
try {
|
|
1195
|
-
await
|
|
1497
|
+
await access3(join5(dir, runner.file));
|
|
1498
|
+
} catch {
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1501
|
+
try {
|
|
1196
1502
|
const { stdout, stderr } = await execAsync(runner.cmd, {
|
|
1197
1503
|
cwd: dir,
|
|
1198
1504
|
timeout: 12e4
|
|
1199
1505
|
});
|
|
1200
1506
|
const combined = stdout + stderr;
|
|
1201
|
-
const hasFailed = /\d+ failed|\d+ error
|
|
1507
|
+
const hasFailed = /\d+ failed|\d+ error/i.test(combined);
|
|
1202
1508
|
return !hasFailed;
|
|
1203
|
-
} catch {
|
|
1509
|
+
} catch (err) {
|
|
1510
|
+
if (err && typeof err === "object" && "code" in err && typeof err.code === "number") {
|
|
1511
|
+
return false;
|
|
1512
|
+
}
|
|
1204
1513
|
continue;
|
|
1205
1514
|
}
|
|
1206
1515
|
}
|
|
@@ -1208,7 +1517,7 @@ async function runTests(dir) {
|
|
|
1208
1517
|
}
|
|
1209
1518
|
async function evaluateSuccessCriteria(dir) {
|
|
1210
1519
|
try {
|
|
1211
|
-
const content = await
|
|
1520
|
+
const content = await readFile4(join5(dir, "SUCCESS_CRITERIA.md"), "utf-8");
|
|
1212
1521
|
const lines = content.split("\n");
|
|
1213
1522
|
const results = [];
|
|
1214
1523
|
for (const line of lines) {
|
|
@@ -1227,7 +1536,7 @@ async function evaluateSuccessCriteria(dir) {
|
|
|
1227
1536
|
}
|
|
1228
1537
|
async function loadExistingReport(dir) {
|
|
1229
1538
|
try {
|
|
1230
|
-
const raw = await
|
|
1539
|
+
const raw = await readFile4(join5(dir, "run-report.json"), "utf-8");
|
|
1231
1540
|
return JSON.parse(raw);
|
|
1232
1541
|
} catch {
|
|
1233
1542
|
return null;
|
|
@@ -1265,8 +1574,8 @@ async function handleRun(specPathOrId, opts) {
|
|
|
1265
1574
|
console.log(chalk6.yellow(` \u26A0 ${warning}`));
|
|
1266
1575
|
}
|
|
1267
1576
|
}
|
|
1268
|
-
const specYamlContent = await
|
|
1269
|
-
const specYamlRaw =
|
|
1577
|
+
const specYamlContent = await readFile5(join6(specDir, "spec.yaml"), "utf-8");
|
|
1578
|
+
const specYamlRaw = parseYaml3(specYamlContent);
|
|
1270
1579
|
const specYaml = specYamlSchema.parse(specYamlRaw);
|
|
1271
1580
|
console.log("");
|
|
1272
1581
|
console.log(chalk6.yellow("\u26A0 SECURITY WARNING"));
|
|
@@ -1284,6 +1593,13 @@ async function handleRun(specPathOrId, opts) {
|
|
|
1284
1593
|
if (authed && !opts.noTelemetry) {
|
|
1285
1594
|
await promptTelemetryOptIn();
|
|
1286
1595
|
}
|
|
1596
|
+
try {
|
|
1597
|
+
await checkClaudeCliInstalled();
|
|
1598
|
+
} catch (err) {
|
|
1599
|
+
console.log(chalk6.red(`
|
|
1600
|
+
\u2717 ${err.message}`));
|
|
1601
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
1602
|
+
}
|
|
1287
1603
|
const maxLoops = opts.maxLoops ? parseInt(opts.maxLoops, 10) : void 0;
|
|
1288
1604
|
const maxBudget = opts.maxBudget ? parseFloat(opts.maxBudget) : void 0;
|
|
1289
1605
|
console.log(chalk6.cyan(`
|
|
@@ -1375,10 +1691,10 @@ async function resolveSpecPath(pathOrId) {
|
|
|
1375
1691
|
debug7("Treating %s as local path (no registry pattern match)", pathOrId);
|
|
1376
1692
|
return { specDir: resolve4(pathOrId) };
|
|
1377
1693
|
}
|
|
1378
|
-
const { access:
|
|
1694
|
+
const { access: access4 } = await import("fs/promises");
|
|
1379
1695
|
const localPath = resolve4(pathOrId);
|
|
1380
1696
|
try {
|
|
1381
|
-
await
|
|
1697
|
+
await access4(localPath);
|
|
1382
1698
|
debug7("Found local directory %s \u2014 using as local spec", localPath);
|
|
1383
1699
|
return { specDir: localPath };
|
|
1384
1700
|
} catch {
|
|
@@ -1442,7 +1758,7 @@ async function resolveSpecPath(pathOrId) {
|
|
|
1442
1758
|
debug7("Got download URL for %s@%s", scopedName, resolvedVersion);
|
|
1443
1759
|
const { tmpdir } = await import("os");
|
|
1444
1760
|
const { randomUUID: randomUUID2 } = await import("crypto");
|
|
1445
|
-
const tempDir =
|
|
1761
|
+
const tempDir = join6(tmpdir(), `specmarket-${randomUUID2()}`);
|
|
1446
1762
|
await mkdir4(tempDir, { recursive: true });
|
|
1447
1763
|
let response;
|
|
1448
1764
|
try {
|
|
@@ -1459,7 +1775,7 @@ async function resolveSpecPath(pathOrId) {
|
|
|
1459
1775
|
throw err;
|
|
1460
1776
|
}
|
|
1461
1777
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1462
|
-
const zipPath =
|
|
1778
|
+
const zipPath = join6(tempDir, "spec.zip");
|
|
1463
1779
|
await writeFileFn(zipPath, buffer);
|
|
1464
1780
|
const { execAsync: execAsync2 } = await import("./exec-K3BOXX3C.js");
|
|
1465
1781
|
await execAsync2(`unzip -q "${zipPath}" -d "${tempDir}"`);
|
|
@@ -1471,7 +1787,7 @@ async function resolveSpecPath(pathOrId) {
|
|
|
1471
1787
|
return { specDir: tempDir, registrySpecId };
|
|
1472
1788
|
}
|
|
1473
1789
|
function createRunCommand() {
|
|
1474
|
-
return new Command6("run").description("Execute a spec locally using the Ralph Loop").argument("
|
|
1790
|
+
return new Command6("run").description("Execute a spec locally using the Ralph Loop").argument("[path-or-id]", "Local path to spec directory or registry ID (@user/name[@version])", ".").option("--max-loops <n>", "Maximum loop iterations (default: 50)").option("--max-budget <usd>", "Maximum budget in USD (default: 2x estimated)").option("--no-telemetry", "Disable telemetry submission for this run").option("--model <model>", "Override AI model (default: spec's min_model)").option("--dry-run", "Validate and show config without executing").option("--resume <run-id>", "Resume a previous run from where it left off").option("--output <dir>", "Custom output directory for run artifacts").action(async (pathOrId, opts) => {
|
|
1475
1791
|
try {
|
|
1476
1792
|
await handleRun(pathOrId, opts);
|
|
1477
1793
|
} catch (err) {
|
|
@@ -1649,11 +1965,43 @@ async function handleInfo(specId) {
|
|
|
1649
1965
|
const spinner = (await import("ora")).default(`Loading info for ${specId}...`).start();
|
|
1650
1966
|
try {
|
|
1651
1967
|
const isScopedName = specId.startsWith("@") || specId.includes("/");
|
|
1652
|
-
const [spec, stats,
|
|
1968
|
+
const [spec, stats, versionsResult] = await Promise.all([
|
|
1653
1969
|
client.query(api2.specs.get, isScopedName ? { scopedName: specId } : { specId }),
|
|
1654
1970
|
client.query(api2.runs.getStats, { specId }).catch(() => null),
|
|
1655
|
-
client.query(api2.specs.getVersions, { specId }).catch(() => [])
|
|
1971
|
+
client.query(api2.specs.getVersions, { specId, paginationOpts: { numItems: 25, cursor: null } }).catch(() => ({ page: [] }))
|
|
1656
1972
|
]);
|
|
1973
|
+
const versions = versionsResult.page;
|
|
1974
|
+
let openIssueCount = 0;
|
|
1975
|
+
let maintainers = [];
|
|
1976
|
+
let commentCount = 0;
|
|
1977
|
+
if (spec) {
|
|
1978
|
+
const [issuesResult, maintainersResult, commentsResult] = await Promise.all([
|
|
1979
|
+
client.query(api2.issues.list, {
|
|
1980
|
+
specId: spec._id,
|
|
1981
|
+
status: "open",
|
|
1982
|
+
paginationOpts: { numItems: 1, cursor: null }
|
|
1983
|
+
}).catch(() => null),
|
|
1984
|
+
client.query(api2.specMaintainers.list, { specId: spec._id }).catch(() => []),
|
|
1985
|
+
client.query(api2.comments.list, {
|
|
1986
|
+
targetType: "spec",
|
|
1987
|
+
targetId: spec._id,
|
|
1988
|
+
paginationOpts: { numItems: 1, cursor: null }
|
|
1989
|
+
}).catch(() => null)
|
|
1990
|
+
]);
|
|
1991
|
+
if (issuesResult) {
|
|
1992
|
+
openIssueCount = issuesResult.page.length;
|
|
1993
|
+
if (!issuesResult.isDone && openIssueCount > 0) {
|
|
1994
|
+
openIssueCount = -1;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
maintainers = maintainersResult;
|
|
1998
|
+
if (commentsResult) {
|
|
1999
|
+
commentCount = commentsResult.page.length;
|
|
2000
|
+
if (!commentsResult.isDone && commentCount > 0) {
|
|
2001
|
+
commentCount = -1;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
1657
2005
|
spinner.stop();
|
|
1658
2006
|
if (!spec) {
|
|
1659
2007
|
console.log(chalk8.red(`Spec not found: ${specId}`));
|
|
@@ -1701,6 +2049,18 @@ async function handleInfo(specId) {
|
|
|
1701
2049
|
if (spec.forkedFromId) {
|
|
1702
2050
|
console.log(chalk8.gray(` (Forked from v${spec.forkedFromVersion})`));
|
|
1703
2051
|
}
|
|
2052
|
+
const issueDisplay = openIssueCount === -1 ? "many" : String(openIssueCount);
|
|
2053
|
+
const commentDisplay = commentCount === -1 ? "many" : String(commentCount);
|
|
2054
|
+
console.log(
|
|
2055
|
+
` Open Issues: ${issueDisplay}`
|
|
2056
|
+
);
|
|
2057
|
+
console.log(
|
|
2058
|
+
` Comments: ${commentDisplay}`
|
|
2059
|
+
);
|
|
2060
|
+
if (maintainers.length > 0) {
|
|
2061
|
+
const names = maintainers.filter((m) => m.user).map((m) => `@${m.user.username}`).join(", ");
|
|
2062
|
+
console.log(` Maintainers: ${names}`);
|
|
2063
|
+
}
|
|
1704
2064
|
if (author) {
|
|
1705
2065
|
console.log("");
|
|
1706
2066
|
console.log(chalk8.bold("Creator:"));
|
|
@@ -1785,9 +2145,9 @@ function createInfoCommand() {
|
|
|
1785
2145
|
import { Command as Command9 } from "commander";
|
|
1786
2146
|
import chalk9 from "chalk";
|
|
1787
2147
|
import ora4 from "ora";
|
|
1788
|
-
import { readFile as
|
|
1789
|
-
import { join as
|
|
1790
|
-
import { parse as
|
|
2148
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
2149
|
+
import { join as join7, resolve as resolve5 } from "path";
|
|
2150
|
+
import { parse as parseYaml4 } from "yaml";
|
|
1791
2151
|
import { createWriteStream } from "fs";
|
|
1792
2152
|
async function handlePublish(specPath, opts = {}) {
|
|
1793
2153
|
const creds = await requireAuth();
|
|
@@ -1808,8 +2168,8 @@ async function handlePublish(specPath, opts = {}) {
|
|
|
1808
2168
|
spinner.succeed("Spec validated");
|
|
1809
2169
|
}
|
|
1810
2170
|
spinner.start("Reading spec metadata...");
|
|
1811
|
-
const specYamlContent = await
|
|
1812
|
-
const specYamlRaw =
|
|
2171
|
+
const specYamlContent = await readFile6(join7(dir, "spec.yaml"), "utf-8");
|
|
2172
|
+
const specYamlRaw = parseYaml4(specYamlContent);
|
|
1813
2173
|
const specYaml = specYamlSchema.parse(specYamlRaw);
|
|
1814
2174
|
spinner.succeed(`Loaded spec: ${specYaml.display_name} v${specYaml.version}`);
|
|
1815
2175
|
const client = await getConvexClient(creds.token);
|
|
@@ -1824,7 +2184,7 @@ async function handlePublish(specPath, opts = {}) {
|
|
|
1824
2184
|
spinner.succeed("Spec archive created");
|
|
1825
2185
|
spinner.start("Uploading spec to registry...");
|
|
1826
2186
|
const uploadUrl = await client.mutation(api2.specs.generateUploadUrl, {});
|
|
1827
|
-
const zipContent = await
|
|
2187
|
+
const zipContent = await readFile6(zipPath);
|
|
1828
2188
|
const uploadResponse = await fetch(uploadUrl, {
|
|
1829
2189
|
method: "POST",
|
|
1830
2190
|
headers: { "Content-Type": "application/zip" },
|
|
@@ -1836,7 +2196,7 @@ async function handlePublish(specPath, opts = {}) {
|
|
|
1836
2196
|
const { storageId } = await uploadResponse.json();
|
|
1837
2197
|
spinner.succeed("Spec uploaded");
|
|
1838
2198
|
spinner.start("Publishing to registry...");
|
|
1839
|
-
const readme = await
|
|
2199
|
+
const readme = await readFile6(join7(dir, "SPEC.md"), "utf-8").catch(() => void 0);
|
|
1840
2200
|
const publishResult = await client.mutation(api2.specs.publish, {
|
|
1841
2201
|
slug: specYaml.name,
|
|
1842
2202
|
displayName: specYaml.display_name,
|
|
@@ -1878,7 +2238,7 @@ async function handlePublish(specPath, opts = {}) {
|
|
|
1878
2238
|
async function createSpecZip(dir) {
|
|
1879
2239
|
const { tmpdir } = await import("os");
|
|
1880
2240
|
const { randomUUID: randomUUID2 } = await import("crypto");
|
|
1881
|
-
const zipPath =
|
|
2241
|
+
const zipPath = join7(tmpdir(), `spec-${randomUUID2()}.zip`);
|
|
1882
2242
|
const archiver = (await import("archiver")).default;
|
|
1883
2243
|
const output = createWriteStream(zipPath);
|
|
1884
2244
|
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
@@ -1909,9 +2269,9 @@ function createPublishCommand() {
|
|
|
1909
2269
|
import { Command as Command10 } from "commander";
|
|
1910
2270
|
import chalk10 from "chalk";
|
|
1911
2271
|
import ora5 from "ora";
|
|
1912
|
-
import { mkdir as mkdir5, writeFile as writeFile4, readFile as
|
|
1913
|
-
import { join as
|
|
1914
|
-
import { parse as
|
|
2272
|
+
import { mkdir as mkdir5, writeFile as writeFile4, readFile as readFile7 } from "fs/promises";
|
|
2273
|
+
import { join as join8, resolve as resolve6 } from "path";
|
|
2274
|
+
import { parse as parseYaml5, stringify as stringifyYaml } from "yaml";
|
|
1915
2275
|
async function handleFork(specId, targetPath) {
|
|
1916
2276
|
const creds = await requireAuth();
|
|
1917
2277
|
const spinner = ora5("Loading spec info...").start();
|
|
@@ -1939,9 +2299,9 @@ async function handleFork(specId, targetPath) {
|
|
|
1939
2299
|
const targetDir = resolve6(targetPath ?? spec.slug);
|
|
1940
2300
|
spinner.text = `Extracting to ${targetDir}...`;
|
|
1941
2301
|
await downloadAndExtract(url, targetDir);
|
|
1942
|
-
const specYamlPath =
|
|
1943
|
-
const specYamlContent = await
|
|
1944
|
-
const specYamlData =
|
|
2302
|
+
const specYamlPath = join8(targetDir, "spec.yaml");
|
|
2303
|
+
const specYamlContent = await readFile7(specYamlPath, "utf-8");
|
|
2304
|
+
const specYamlData = parseYaml5(specYamlContent);
|
|
1945
2305
|
specYamlData["forked_from_id"] = spec._id;
|
|
1946
2306
|
specYamlData["forked_from_version"] = spec.currentVersion;
|
|
1947
2307
|
specYamlData["version"] = "1.0.0";
|
|
@@ -1974,7 +2334,7 @@ async function downloadAndExtract(url, targetDir) {
|
|
|
1974
2334
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1975
2335
|
const { tmpdir } = await import("os");
|
|
1976
2336
|
const { randomUUID: randomUUID2 } = await import("crypto");
|
|
1977
|
-
const zipPath =
|
|
2337
|
+
const zipPath = join8(tmpdir(), `fork-${randomUUID2()}.zip`);
|
|
1978
2338
|
const { writeFile: writeFileFn2, unlink: unlink2 } = await import("fs/promises");
|
|
1979
2339
|
await writeFileFn2(zipPath, buffer);
|
|
1980
2340
|
await mkdir5(targetDir, { recursive: true });
|
|
@@ -2001,15 +2361,15 @@ function createForkCommand() {
|
|
|
2001
2361
|
// src/commands/report.ts
|
|
2002
2362
|
import { Command as Command11 } from "commander";
|
|
2003
2363
|
import chalk11 from "chalk";
|
|
2004
|
-
import { readFile as
|
|
2005
|
-
import { join as
|
|
2364
|
+
import { readFile as readFile8 } from "fs/promises";
|
|
2365
|
+
import { join as join9 } from "path";
|
|
2006
2366
|
import { homedir as homedir3 } from "os";
|
|
2007
2367
|
async function handleReport(runId) {
|
|
2008
|
-
const localPath =
|
|
2368
|
+
const localPath = join9(homedir3(), CONFIG_PATHS.RUNS_DIR, runId, "run-report.json");
|
|
2009
2369
|
let report = null;
|
|
2010
2370
|
let source = "local";
|
|
2011
2371
|
try {
|
|
2012
|
-
const raw = await
|
|
2372
|
+
const raw = await readFile8(localPath, "utf-8");
|
|
2013
2373
|
report = JSON.parse(raw);
|
|
2014
2374
|
source = "local";
|
|
2015
2375
|
} catch {
|
|
@@ -2257,6 +2617,443 @@ function createConfigCommand() {
|
|
|
2257
2617
|
return configCmd;
|
|
2258
2618
|
}
|
|
2259
2619
|
|
|
2620
|
+
// src/commands/issues.ts
|
|
2621
|
+
import { Command as Command13 } from "commander";
|
|
2622
|
+
import chalk13 from "chalk";
|
|
2623
|
+
import Table2 from "cli-table3";
|
|
2624
|
+
async function loadApi() {
|
|
2625
|
+
try {
|
|
2626
|
+
return (await import("./api-GIDUNUXG.js")).api;
|
|
2627
|
+
} catch {
|
|
2628
|
+
console.error(
|
|
2629
|
+
chalk13.red("Error: Could not load Convex API bindings. Is CONVEX_URL configured?")
|
|
2630
|
+
);
|
|
2631
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
async function resolveSpec(client, api2, specRef) {
|
|
2635
|
+
const isScopedName = specRef.startsWith("@") || specRef.includes("/");
|
|
2636
|
+
const spec = await client.query(
|
|
2637
|
+
api2.specs.get,
|
|
2638
|
+
isScopedName ? { scopedName: specRef } : { specId: specRef }
|
|
2639
|
+
);
|
|
2640
|
+
if (!spec) {
|
|
2641
|
+
console.error(chalk13.red(`Spec not found: ${specRef}`));
|
|
2642
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
2643
|
+
}
|
|
2644
|
+
return spec;
|
|
2645
|
+
}
|
|
2646
|
+
function relativeTime(timestamp) {
|
|
2647
|
+
const diff = Date.now() - timestamp;
|
|
2648
|
+
const seconds = Math.floor(diff / 1e3);
|
|
2649
|
+
const minutes = Math.floor(seconds / 60);
|
|
2650
|
+
const hours = Math.floor(minutes / 60);
|
|
2651
|
+
const days = Math.floor(hours / 24);
|
|
2652
|
+
if (days > 0) return `${days}d ago`;
|
|
2653
|
+
if (hours > 0) return `${hours}h ago`;
|
|
2654
|
+
if (minutes > 0) return `${minutes}m ago`;
|
|
2655
|
+
return "just now";
|
|
2656
|
+
}
|
|
2657
|
+
async function handleIssuesList(specRef, opts) {
|
|
2658
|
+
const api2 = await loadApi();
|
|
2659
|
+
const client = await getConvexClient();
|
|
2660
|
+
const spinner = (await import("ora")).default("Loading issues...").start();
|
|
2661
|
+
try {
|
|
2662
|
+
const spec = await resolveSpec(client, api2, specRef);
|
|
2663
|
+
const statusFilter = opts.status === "all" ? void 0 : opts.status ?? "open";
|
|
2664
|
+
const result = await client.query(api2.issues.list, {
|
|
2665
|
+
specId: spec._id,
|
|
2666
|
+
status: statusFilter,
|
|
2667
|
+
paginationOpts: { numItems: 50, cursor: null }
|
|
2668
|
+
});
|
|
2669
|
+
spinner.stop();
|
|
2670
|
+
let issues = result.page;
|
|
2671
|
+
if (opts.label) {
|
|
2672
|
+
const label = opts.label.toLowerCase();
|
|
2673
|
+
issues = issues.filter(
|
|
2674
|
+
(i) => i.labels.some((l) => l.toLowerCase() === label)
|
|
2675
|
+
);
|
|
2676
|
+
}
|
|
2677
|
+
if (issues.length === 0) {
|
|
2678
|
+
const statusLabel = statusFilter ?? "any";
|
|
2679
|
+
console.log(chalk13.gray(`No ${statusLabel} issues for ${spec.scopedName}`));
|
|
2680
|
+
return;
|
|
2681
|
+
}
|
|
2682
|
+
console.log(
|
|
2683
|
+
chalk13.bold(`
|
|
2684
|
+
${issues.length} issue(s) for ${spec.scopedName}:
|
|
2685
|
+
`)
|
|
2686
|
+
);
|
|
2687
|
+
const table = new Table2({
|
|
2688
|
+
head: [
|
|
2689
|
+
chalk13.cyan("#"),
|
|
2690
|
+
chalk13.cyan("Title"),
|
|
2691
|
+
chalk13.cyan("Author"),
|
|
2692
|
+
chalk13.cyan("Age"),
|
|
2693
|
+
chalk13.cyan("Labels")
|
|
2694
|
+
],
|
|
2695
|
+
style: { compact: true },
|
|
2696
|
+
colWidths: [6, 40, 16, 10, 20],
|
|
2697
|
+
wordWrap: true
|
|
2698
|
+
});
|
|
2699
|
+
for (const issue of issues) {
|
|
2700
|
+
const statusIcon = issue.status === "open" ? chalk13.green("\u25CF") : chalk13.gray("\u25CB");
|
|
2701
|
+
table.push([
|
|
2702
|
+
`${statusIcon} ${issue.number}`,
|
|
2703
|
+
issue.title.slice(0, 60),
|
|
2704
|
+
issue.author ? `@${issue.author.username}` : chalk13.gray("unknown"),
|
|
2705
|
+
relativeTime(issue.createdAt),
|
|
2706
|
+
issue.labels.length > 0 ? issue.labels.join(", ") : chalk13.gray("\u2014")
|
|
2707
|
+
]);
|
|
2708
|
+
}
|
|
2709
|
+
console.log(table.toString());
|
|
2710
|
+
console.log(
|
|
2711
|
+
chalk13.gray(
|
|
2712
|
+
`
|
|
2713
|
+
View: ${chalk13.cyan(`specmarket issues ${specRef} <number>`)}`
|
|
2714
|
+
)
|
|
2715
|
+
);
|
|
2716
|
+
} catch (err) {
|
|
2717
|
+
spinner.fail(chalk13.red(`Failed to load issues: ${err.message}`));
|
|
2718
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
async function handleIssuesCreate(specRef) {
|
|
2722
|
+
const creds = await requireAuth();
|
|
2723
|
+
const api2 = await loadApi();
|
|
2724
|
+
const client = await getConvexClient(creds.token);
|
|
2725
|
+
const spec = await resolveSpec(client, api2, specRef);
|
|
2726
|
+
const { default: inquirer } = await import("inquirer");
|
|
2727
|
+
const answers = await inquirer.prompt([
|
|
2728
|
+
{
|
|
2729
|
+
type: "input",
|
|
2730
|
+
name: "title",
|
|
2731
|
+
message: "Issue title:",
|
|
2732
|
+
validate: (v) => v.trim().length > 0 || "Title cannot be empty"
|
|
2733
|
+
},
|
|
2734
|
+
{
|
|
2735
|
+
type: "editor",
|
|
2736
|
+
name: "body",
|
|
2737
|
+
message: "Issue body (markdown):",
|
|
2738
|
+
validate: (v) => v.trim().length > 0 || "Body cannot be empty"
|
|
2739
|
+
}
|
|
2740
|
+
]);
|
|
2741
|
+
const spinner = (await import("ora")).default("Creating issue...").start();
|
|
2742
|
+
try {
|
|
2743
|
+
const result = await client.mutation(api2.issues.create, {
|
|
2744
|
+
specId: spec._id,
|
|
2745
|
+
title: answers.title.trim(),
|
|
2746
|
+
body: answers.body.trim(),
|
|
2747
|
+
labels: []
|
|
2748
|
+
});
|
|
2749
|
+
spinner.succeed(
|
|
2750
|
+
chalk13.green(`Issue #${result.number} created on ${spec.scopedName}`)
|
|
2751
|
+
);
|
|
2752
|
+
} catch (err) {
|
|
2753
|
+
spinner.fail(chalk13.red(`Failed to create issue: ${err.message}`));
|
|
2754
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
async function handleIssuesView(specRef, issueNumber) {
|
|
2758
|
+
const api2 = await loadApi();
|
|
2759
|
+
const client = await getConvexClient();
|
|
2760
|
+
const spinner = (await import("ora")).default(`Loading issue #${issueNumber}...`).start();
|
|
2761
|
+
try {
|
|
2762
|
+
const spec = await resolveSpec(client, api2, specRef);
|
|
2763
|
+
const issue = await client.query(api2.issues.get, {
|
|
2764
|
+
specId: spec._id,
|
|
2765
|
+
number: issueNumber
|
|
2766
|
+
});
|
|
2767
|
+
if (!issue) {
|
|
2768
|
+
spinner.fail(chalk13.red(`Issue #${issueNumber} not found on ${spec.scopedName}`));
|
|
2769
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
2770
|
+
}
|
|
2771
|
+
const commentsResult = await client.query(api2.comments.list, {
|
|
2772
|
+
targetType: "issue",
|
|
2773
|
+
targetId: issue._id,
|
|
2774
|
+
paginationOpts: { numItems: 10, cursor: null }
|
|
2775
|
+
});
|
|
2776
|
+
spinner.stop();
|
|
2777
|
+
const statusBadge = issue.status === "open" ? chalk13.green.bold(" OPEN ") : chalk13.gray.bold(" CLOSED ");
|
|
2778
|
+
console.log("");
|
|
2779
|
+
console.log(
|
|
2780
|
+
`${statusBadge} ${chalk13.bold(`#${issue.number}: ${issue.title}`)}`
|
|
2781
|
+
);
|
|
2782
|
+
console.log(chalk13.gray("\u2500".repeat(60)));
|
|
2783
|
+
console.log(
|
|
2784
|
+
`${chalk13.bold("Author:")} ${issue.author ? `@${issue.author.username}` : "unknown"} ${chalk13.bold("Created:")} ${new Date(issue.createdAt).toLocaleDateString()}`
|
|
2785
|
+
);
|
|
2786
|
+
if (issue.labels.length > 0) {
|
|
2787
|
+
console.log(`${chalk13.bold("Labels:")} ${issue.labels.join(", ")}`);
|
|
2788
|
+
}
|
|
2789
|
+
if (issue.closedAt) {
|
|
2790
|
+
console.log(
|
|
2791
|
+
`${chalk13.bold("Closed:")} ${new Date(issue.closedAt).toLocaleDateString()}`
|
|
2792
|
+
);
|
|
2793
|
+
}
|
|
2794
|
+
console.log("");
|
|
2795
|
+
console.log(issue.body);
|
|
2796
|
+
console.log("");
|
|
2797
|
+
if (commentsResult.page.length > 0) {
|
|
2798
|
+
console.log(
|
|
2799
|
+
chalk13.bold(`Comments (${issue.commentCount}):`)
|
|
2800
|
+
);
|
|
2801
|
+
console.log(chalk13.gray("\u2500".repeat(40)));
|
|
2802
|
+
for (const comment of commentsResult.page) {
|
|
2803
|
+
const author = comment.author ? `@${comment.author.username}` : "unknown";
|
|
2804
|
+
const edited = comment.editedAt ? chalk13.gray(" (edited)") : "";
|
|
2805
|
+
console.log(
|
|
2806
|
+
` ${chalk13.bold(author)} \u2014 ${relativeTime(comment.createdAt)}${edited}`
|
|
2807
|
+
);
|
|
2808
|
+
console.log(` ${comment.body}`);
|
|
2809
|
+
if (comment.replies && comment.replies.length > 0) {
|
|
2810
|
+
for (const reply of comment.replies) {
|
|
2811
|
+
const replyAuthor = reply.author ? `@${reply.author.username}` : "unknown";
|
|
2812
|
+
const replyEdited = reply.editedAt ? chalk13.gray(" (edited)") : "";
|
|
2813
|
+
console.log(
|
|
2814
|
+
` ${chalk13.bold(replyAuthor)} \u2014 ${relativeTime(reply.createdAt)}${replyEdited}`
|
|
2815
|
+
);
|
|
2816
|
+
console.log(` ${reply.body}`);
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
console.log("");
|
|
2820
|
+
}
|
|
2821
|
+
} else {
|
|
2822
|
+
console.log(chalk13.gray("No comments yet."));
|
|
2823
|
+
}
|
|
2824
|
+
} catch (err) {
|
|
2825
|
+
spinner.fail(
|
|
2826
|
+
chalk13.red(`Failed to load issue: ${err.message}`)
|
|
2827
|
+
);
|
|
2828
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
async function handleIssuesClose(specRef, issueNumber) {
|
|
2832
|
+
const creds = await requireAuth();
|
|
2833
|
+
const api2 = await loadApi();
|
|
2834
|
+
const client = await getConvexClient(creds.token);
|
|
2835
|
+
const spinner = (await import("ora")).default(`Closing issue #${issueNumber}...`).start();
|
|
2836
|
+
try {
|
|
2837
|
+
const spec = await resolveSpec(client, api2, specRef);
|
|
2838
|
+
const issue = await client.query(api2.issues.get, {
|
|
2839
|
+
specId: spec._id,
|
|
2840
|
+
number: issueNumber
|
|
2841
|
+
});
|
|
2842
|
+
if (!issue) {
|
|
2843
|
+
spinner.fail(chalk13.red(`Issue #${issueNumber} not found on ${spec.scopedName}`));
|
|
2844
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
2845
|
+
}
|
|
2846
|
+
await client.mutation(api2.issues.close, {
|
|
2847
|
+
issueId: issue._id
|
|
2848
|
+
});
|
|
2849
|
+
spinner.succeed(
|
|
2850
|
+
chalk13.green(`Issue #${issueNumber} closed on ${spec.scopedName}`)
|
|
2851
|
+
);
|
|
2852
|
+
} catch (err) {
|
|
2853
|
+
spinner.fail(chalk13.red(`Failed to close issue: ${err.message}`));
|
|
2854
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
async function handleIssuesReopen(specRef, issueNumber) {
|
|
2858
|
+
const creds = await requireAuth();
|
|
2859
|
+
const api2 = await loadApi();
|
|
2860
|
+
const client = await getConvexClient(creds.token);
|
|
2861
|
+
const spinner = (await import("ora")).default(`Reopening issue #${issueNumber}...`).start();
|
|
2862
|
+
try {
|
|
2863
|
+
const spec = await resolveSpec(client, api2, specRef);
|
|
2864
|
+
const issue = await client.query(api2.issues.get, {
|
|
2865
|
+
specId: spec._id,
|
|
2866
|
+
number: issueNumber
|
|
2867
|
+
});
|
|
2868
|
+
if (!issue) {
|
|
2869
|
+
spinner.fail(chalk13.red(`Issue #${issueNumber} not found on ${spec.scopedName}`));
|
|
2870
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
2871
|
+
}
|
|
2872
|
+
await client.mutation(api2.issues.reopen, {
|
|
2873
|
+
issueId: issue._id
|
|
2874
|
+
});
|
|
2875
|
+
spinner.succeed(
|
|
2876
|
+
chalk13.green(`Issue #${issueNumber} reopened on ${spec.scopedName}`)
|
|
2877
|
+
);
|
|
2878
|
+
} catch (err) {
|
|
2879
|
+
spinner.fail(
|
|
2880
|
+
chalk13.red(`Failed to reopen issue: ${err.message}`)
|
|
2881
|
+
);
|
|
2882
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
function createIssuesCommand() {
|
|
2886
|
+
return new Command13("issues").description("Manage issues on a spec").argument("<spec-id>", "Spec scoped name (@user/name) or document ID").argument("[action-or-number]", 'Issue number or "create"').argument("[action]", '"close" or "reopen" (with issue number)').option(
|
|
2887
|
+
"-s, --status <status>",
|
|
2888
|
+
"Filter by status: open, closed, all (default: open)"
|
|
2889
|
+
).option("--label <label>", "Filter by label").action(
|
|
2890
|
+
async (specId, actionOrNumber, action, opts) => {
|
|
2891
|
+
try {
|
|
2892
|
+
if (!actionOrNumber) {
|
|
2893
|
+
await handleIssuesList(specId, opts);
|
|
2894
|
+
} else if (actionOrNumber === "create") {
|
|
2895
|
+
await handleIssuesCreate(specId);
|
|
2896
|
+
} else {
|
|
2897
|
+
const issueNumber = parseInt(actionOrNumber, 10);
|
|
2898
|
+
if (isNaN(issueNumber) || issueNumber < 1) {
|
|
2899
|
+
console.error(
|
|
2900
|
+
chalk13.red(
|
|
2901
|
+
`Invalid issue number or action: "${actionOrNumber}". Use a number or "create".`
|
|
2902
|
+
)
|
|
2903
|
+
);
|
|
2904
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
2905
|
+
}
|
|
2906
|
+
if (!action) {
|
|
2907
|
+
await handleIssuesView(specId, issueNumber);
|
|
2908
|
+
} else if (action === "close") {
|
|
2909
|
+
await handleIssuesClose(specId, issueNumber);
|
|
2910
|
+
} else if (action === "reopen") {
|
|
2911
|
+
await handleIssuesReopen(specId, issueNumber);
|
|
2912
|
+
} else {
|
|
2913
|
+
console.error(
|
|
2914
|
+
chalk13.red(
|
|
2915
|
+
`Unknown action: "${action}". Use "close" or "reopen".`
|
|
2916
|
+
)
|
|
2917
|
+
);
|
|
2918
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
} catch (err) {
|
|
2922
|
+
const error = err;
|
|
2923
|
+
if (error.code === String(EXIT_CODES.AUTH_ERROR)) {
|
|
2924
|
+
console.error(chalk13.red(error.message));
|
|
2925
|
+
process.exit(EXIT_CODES.AUTH_ERROR);
|
|
2926
|
+
}
|
|
2927
|
+
console.error(chalk13.red(`Error: ${error.message}`));
|
|
2928
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
);
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
// src/commands/comment.ts
|
|
2935
|
+
import { Command as Command14 } from "commander";
|
|
2936
|
+
import chalk14 from "chalk";
|
|
2937
|
+
async function handleComment(targetType, targetRef, body, opts) {
|
|
2938
|
+
const creds = await requireAuth();
|
|
2939
|
+
let api2;
|
|
2940
|
+
try {
|
|
2941
|
+
api2 = (await import("./api-GIDUNUXG.js")).api;
|
|
2942
|
+
} catch {
|
|
2943
|
+
console.error(
|
|
2944
|
+
chalk14.red("Error: Could not load Convex API bindings. Is CONVEX_URL configured?")
|
|
2945
|
+
);
|
|
2946
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
2947
|
+
}
|
|
2948
|
+
const client = await getConvexClient(creds.token);
|
|
2949
|
+
const spinner = (await import("ora")).default("Posting comment...").start();
|
|
2950
|
+
try {
|
|
2951
|
+
let resolvedTargetType;
|
|
2952
|
+
let resolvedTargetId;
|
|
2953
|
+
if (targetType === "spec") {
|
|
2954
|
+
resolvedTargetType = "spec";
|
|
2955
|
+
const isScopedName = targetRef.startsWith("@") || targetRef.includes("/");
|
|
2956
|
+
const spec = await client.query(
|
|
2957
|
+
api2.specs.get,
|
|
2958
|
+
isScopedName ? { scopedName: targetRef } : { specId: targetRef }
|
|
2959
|
+
);
|
|
2960
|
+
if (!spec) {
|
|
2961
|
+
spinner.fail(chalk14.red(`Spec not found: ${targetRef}`));
|
|
2962
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
2963
|
+
}
|
|
2964
|
+
resolvedTargetId = spec._id;
|
|
2965
|
+
} else if (targetType === "issue") {
|
|
2966
|
+
resolvedTargetType = "issue";
|
|
2967
|
+
const hashIndex = targetRef.lastIndexOf("#");
|
|
2968
|
+
if (hashIndex === -1) {
|
|
2969
|
+
spinner.fail(
|
|
2970
|
+
chalk14.red(
|
|
2971
|
+
"Invalid issue reference. Use format: @user/spec#<number>"
|
|
2972
|
+
)
|
|
2973
|
+
);
|
|
2974
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
2975
|
+
}
|
|
2976
|
+
const specRef = targetRef.slice(0, hashIndex);
|
|
2977
|
+
const issueNumber = parseInt(targetRef.slice(hashIndex + 1), 10);
|
|
2978
|
+
if (isNaN(issueNumber) || issueNumber < 1) {
|
|
2979
|
+
spinner.fail(
|
|
2980
|
+
chalk14.red(`Invalid issue number in "${targetRef}". Use format: @user/spec#<number>`)
|
|
2981
|
+
);
|
|
2982
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
2983
|
+
}
|
|
2984
|
+
const isScopedName = specRef.startsWith("@") || specRef.includes("/");
|
|
2985
|
+
const spec = await client.query(
|
|
2986
|
+
api2.specs.get,
|
|
2987
|
+
isScopedName ? { scopedName: specRef } : { specId: specRef }
|
|
2988
|
+
);
|
|
2989
|
+
if (!spec) {
|
|
2990
|
+
spinner.fail(chalk14.red(`Spec not found: ${specRef}`));
|
|
2991
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
2992
|
+
}
|
|
2993
|
+
const issue = await client.query(api2.issues.get, {
|
|
2994
|
+
specId: spec._id,
|
|
2995
|
+
number: issueNumber
|
|
2996
|
+
});
|
|
2997
|
+
if (!issue) {
|
|
2998
|
+
spinner.fail(
|
|
2999
|
+
chalk14.red(`Issue #${issueNumber} not found on ${spec.scopedName}`)
|
|
3000
|
+
);
|
|
3001
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
3002
|
+
}
|
|
3003
|
+
resolvedTargetId = issue._id;
|
|
3004
|
+
} else if (targetType === "bounty") {
|
|
3005
|
+
resolvedTargetType = "bounty";
|
|
3006
|
+
resolvedTargetId = targetRef;
|
|
3007
|
+
} else {
|
|
3008
|
+
spinner.fail(
|
|
3009
|
+
chalk14.red(
|
|
3010
|
+
`Invalid target type: "${targetType}". Use "spec", "issue", or "bounty".`
|
|
3011
|
+
)
|
|
3012
|
+
);
|
|
3013
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
3014
|
+
}
|
|
3015
|
+
const args = {
|
|
3016
|
+
targetType: resolvedTargetType,
|
|
3017
|
+
targetId: resolvedTargetId,
|
|
3018
|
+
body: body.trim()
|
|
3019
|
+
};
|
|
3020
|
+
if (opts.reply) {
|
|
3021
|
+
args.parentId = opts.reply;
|
|
3022
|
+
}
|
|
3023
|
+
await client.mutation(api2.comments.create, args);
|
|
3024
|
+
spinner.succeed(chalk14.green(`Comment posted on ${targetType} ${targetRef}`));
|
|
3025
|
+
} catch (err) {
|
|
3026
|
+
spinner.fail(chalk14.red(`Failed to post comment: ${err.message}`));
|
|
3027
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
function createCommentCommand() {
|
|
3031
|
+
return new Command14("comment").description("Post a comment on a spec, issue, or bounty (requires login)").argument(
|
|
3032
|
+
"<target-type>",
|
|
3033
|
+
"Target type: spec, issue, or bounty"
|
|
3034
|
+
).argument(
|
|
3035
|
+
"<target-ref>",
|
|
3036
|
+
"Target reference (e.g., @user/spec, @user/spec#3, bounty-id)"
|
|
3037
|
+
).argument("<body>", "Comment body text").option(
|
|
3038
|
+
"--reply <comment-id>",
|
|
3039
|
+
"Reply to a specific comment (threading)"
|
|
3040
|
+
).action(
|
|
3041
|
+
async (targetType, targetRef, body, opts) => {
|
|
3042
|
+
try {
|
|
3043
|
+
await handleComment(targetType, targetRef, body, opts);
|
|
3044
|
+
} catch (err) {
|
|
3045
|
+
const error = err;
|
|
3046
|
+
if (error.code === String(EXIT_CODES.AUTH_ERROR)) {
|
|
3047
|
+
console.error(chalk14.red(error.message));
|
|
3048
|
+
process.exit(EXIT_CODES.AUTH_ERROR);
|
|
3049
|
+
}
|
|
3050
|
+
console.error(chalk14.red(`Error: ${error.message}`));
|
|
3051
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
);
|
|
3055
|
+
}
|
|
3056
|
+
|
|
2260
3057
|
// src/index.ts
|
|
2261
3058
|
import { createRequire as createRequire2 } from "module";
|
|
2262
3059
|
var _require2 = createRequire2(import.meta.url);
|
|
@@ -2277,6 +3074,8 @@ program.addCommand(createSearchCommand());
|
|
|
2277
3074
|
program.addCommand(createInfoCommand());
|
|
2278
3075
|
program.addCommand(createPublishCommand());
|
|
2279
3076
|
program.addCommand(createForkCommand());
|
|
3077
|
+
program.addCommand(createIssuesCommand());
|
|
3078
|
+
program.addCommand(createCommentCommand());
|
|
2280
3079
|
program.addCommand(createReportCommand());
|
|
2281
3080
|
program.addCommand(createConfigCommand());
|
|
2282
3081
|
program.parseAsync(process.argv).catch((err) => {
|