ai-spec-dev 0.42.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +33 -17
  2. package/cli/commands/create.ts +232 -11
  3. package/cli/commands/init.ts +310 -107
  4. package/cli/commands/model.ts +7 -11
  5. package/cli/index.ts +1 -1
  6. package/cli/utils.ts +72 -4
  7. package/core/config-defaults.ts +44 -0
  8. package/core/constitution-generator.ts +2 -1
  9. package/core/dsl-extractor.ts +2 -1
  10. package/core/error-feedback.ts +3 -2
  11. package/core/openapi-exporter.ts +3 -2
  12. package/core/repo-store.ts +95 -0
  13. package/core/reviewer.ts +14 -13
  14. package/core/run-logger.ts +3 -4
  15. package/core/run-snapshot.ts +2 -3
  16. package/core/run-trend.ts +3 -4
  17. package/core/spec-generator.ts +27 -42
  18. package/core/token-budget.ts +3 -8
  19. package/core/vcr.ts +3 -1
  20. package/dist/cli/index.js +919 -519
  21. package/dist/cli/index.js.map +1 -1
  22. package/dist/cli/index.mjs +912 -512
  23. package/dist/cli/index.mjs.map +1 -1
  24. package/dist/index.d.mts +3 -2
  25. package/dist/index.d.ts +3 -2
  26. package/dist/index.js +43 -53
  27. package/dist/index.js.map +1 -1
  28. package/dist/index.mjs +43 -53
  29. package/dist/index.mjs.map +1 -1
  30. package/package.json +1 -1
  31. package/demo-backend/.ai-spec-constitution.md +0 -65
  32. package/demo-backend/package.json +0 -21
  33. package/demo-backend/prisma/schema.prisma +0 -22
  34. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.dsl.json +0 -186
  35. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.md +0 -211
  36. package/demo-backend/src/controllers/bookmark.controller.test.ts +0 -255
  37. package/demo-backend/src/controllers/bookmark.controller.ts +0 -187
  38. package/demo-backend/src/index.ts +0 -17
  39. package/demo-backend/src/routes/bookmark.routes.test.ts +0 -264
  40. package/demo-backend/src/routes/bookmark.routes.ts +0 -11
  41. package/demo-backend/src/routes/index.ts +0 -8
  42. package/demo-backend/src/services/bookmark.service.test.ts +0 -433
  43. package/demo-backend/src/services/bookmark.service.ts +0 -261
  44. package/demo-backend/tsconfig.json +0 -12
  45. package/demo-frontend/.ai-spec-constitution.md +0 -95
  46. package/demo-frontend/package.json +0 -23
  47. package/demo-frontend/src/App.tsx +0 -12
  48. package/demo-frontend/src/main.tsx +0 -9
  49. package/demo-frontend/tsconfig.json +0 -13
@@ -2,7 +2,9 @@ import { Command } from "commander";
2
2
  import * as path from "path";
3
3
  import * as fs from "fs-extra";
4
4
  import chalk from "chalk";
5
- import { createProvider, DEFAULT_MODELS, SUPPORTED_PROVIDERS } from "../../core/spec-generator";
5
+ import { input, select, confirm } from "@inquirer/prompts";
6
+ import { RepoRole } from "../../core/workspace-loader";
7
+ import { createProvider, DEFAULT_MODELS, SUPPORTED_PROVIDERS, AIProvider } from "../../core/spec-generator";
6
8
  import { ContextLoader } from "../../core/context-loader";
7
9
  import { ConstitutionGenerator, CONSTITUTION_FILE } from "../../core/constitution-generator";
8
10
  import { ConstitutionConsolidator } from "../../core/constitution-consolidator";
@@ -17,11 +19,200 @@ import {
17
19
  } from "../../prompts/global-constitution.prompt";
18
20
  import { loadConfig, resolveApiKey } from "../utils";
19
21
  import { loadIndex, ProjectEntry } from "../../core/project-index";
22
+ import { detectRepoType } from "../../core/workspace-loader";
23
+ import {
24
+ RegisteredRepo,
25
+ getRegisteredRepos,
26
+ registerRepo,
27
+ REPO_STORE_FILE,
28
+ } from "../../core/repo-store";
29
+
30
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
31
+
32
+ const ROLE_LABELS: Record<RepoRole, string> = {
33
+ frontend: "frontend",
34
+ backend: "backend",
35
+ mobile: "mobile",
36
+ shared: "shared",
37
+ };
38
+
39
+ /**
40
+ * Prompt user to select a repo role.
41
+ */
42
+ async function promptRepoRole(): Promise<RepoRole> {
43
+ return select<RepoRole>({
44
+ message: "What type of repo is this?",
45
+ choices: [
46
+ { name: "Frontend", value: "frontend" },
47
+ { name: "Backend", value: "backend" },
48
+ { name: "Mobile", value: "mobile" },
49
+ { name: "Shared / Other", value: "shared" },
50
+ ],
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Prompt user for a repo absolute path with validation.
56
+ */
57
+ async function promptRepoPath(role: RepoRole): Promise<string> {
58
+ const label = ROLE_LABELS[role];
59
+ const raw = await input({
60
+ message: `Enter your ${label} repo path (absolute path):`,
61
+ validate: (v) => {
62
+ const trimmed = v.trim();
63
+ if (trimmed.length === 0) return "Path cannot be empty";
64
+ if (!path.isAbsolute(trimmed)) return "Please provide an absolute path";
65
+ return true;
66
+ },
67
+ });
68
+
69
+ // Strip shell escape backslashes (e.g. "文稿\ -\ hongzhong" → "文稿 - hongzhong")
70
+ const cleaned = raw.trim().replace(/\\ /g, " ");
71
+ const resolved = path.resolve(cleaned);
72
+ if (!(await fs.pathExists(resolved))) {
73
+ console.log(chalk.red(` Path does not exist: ${resolved}`));
74
+ return promptRepoPath(role);
75
+ }
76
+ const stat = await fs.stat(resolved);
77
+ if (!stat.isDirectory()) {
78
+ console.log(chalk.red(` Not a directory: ${resolved}`));
79
+ return promptRepoPath(role);
80
+ }
81
+ return resolved;
82
+ }
83
+
84
+ /**
85
+ * Register a single repo: detect type, generate project constitution, return entry.
86
+ * @param roleOverride — user-selected role (takes priority over auto-detection)
87
+ */
88
+ async function registerSingleRepo(
89
+ repoPath: string,
90
+ provider: AIProvider,
91
+ roleOverride?: RepoRole
92
+ ): Promise<RegisteredRepo> {
93
+ const { type, role: detectedRole } = await detectRepoType(repoPath);
94
+ const role = roleOverride ?? detectedRole;
95
+ const repoName = path.basename(repoPath);
96
+
97
+ console.log(chalk.gray(` Detected: ${repoName} → ${type} (${role})`));
98
+
99
+ // Generate project constitution
100
+ const constitutionPath = path.join(repoPath, CONSTITUTION_FILE);
101
+ let hasConstitution = await fs.pathExists(constitutionPath);
102
+
103
+ if (!hasConstitution) {
104
+ console.log(chalk.blue(` Generating project constitution for ${repoName}...`));
105
+ try {
106
+ const gen = new ConstitutionGenerator(provider);
107
+ const content = await gen.generate(repoPath);
108
+ await gen.saveConstitution(repoPath, content);
109
+ hasConstitution = true;
110
+ console.log(chalk.green(` ✔ Constitution saved: ${constitutionPath}`));
111
+ } catch (err) {
112
+ console.log(chalk.yellow(` ⚠ Constitution generation failed: ${(err as Error).message}`));
113
+ }
114
+ } else {
115
+ console.log(chalk.green(` ✔ Constitution already exists: ${constitutionPath}`));
116
+ }
117
+
118
+ const entry: RegisteredRepo = {
119
+ name: repoName,
120
+ path: repoPath,
121
+ type,
122
+ role,
123
+ hasConstitution,
124
+ registeredAt: new Date().toISOString(),
125
+ };
126
+
127
+ await registerRepo(entry);
128
+ return entry;
129
+ }
130
+
131
+ /**
132
+ * Build per-project summaries for global constitution generation from registered repos.
133
+ */
134
+ async function buildProjectSummaries(
135
+ repos: RegisteredRepo[]
136
+ ): Promise<Array<{ name: string; summary: string }>> {
137
+ const summaries: Array<{ name: string; summary: string }> = [];
138
+
139
+ for (const repo of repos) {
140
+ if (!(await fs.pathExists(repo.path))) continue;
141
+
142
+ const lines: string[] = [
143
+ `Type: ${repo.type} (${repo.role})`,
144
+ ];
145
+
146
+ // Load tech stack from context
147
+ try {
148
+ const loader = new ContextLoader(repo.path);
149
+ const ctx = await loader.loadProjectContext();
150
+ lines.push(`Tech stack: ${ctx.techStack.join(", ") || "unknown"}`);
151
+ lines.push(`Dependencies: ${ctx.dependencies.slice(0, 20).join(", ")}`);
152
+ } catch {
153
+ lines.push(`Tech stack: ${repo.type}`);
154
+ }
155
+
156
+ // Include constitution excerpt if available
157
+ if (repo.hasConstitution) {
158
+ try {
159
+ const constitutionPath = path.join(repo.path, CONSTITUTION_FILE);
160
+ const raw = await fs.readFile(constitutionPath, "utf-8");
161
+ lines.push("", "Constitution excerpt:", raw.slice(0, 2000));
162
+ } catch { /* skip */ }
163
+ }
164
+
165
+ summaries.push({ name: repo.name, summary: lines.join("\n") });
166
+ }
167
+
168
+ return summaries;
169
+ }
170
+
171
+ /**
172
+ * Generate or update global constitution based on all registered repos.
173
+ */
174
+ async function generateGlobalConstitution(
175
+ provider: AIProvider,
176
+ repos: RegisteredRepo[],
177
+ currentDir: string
178
+ ): Promise<void> {
179
+ console.log(chalk.blue("\n─── Generating Global Constitution ──────────────"));
180
+ console.log(chalk.gray(` Based on ${repos.length} registered repo(s)`));
181
+
182
+ const summaries = await buildProjectSummaries(repos);
183
+ if (summaries.length === 0) {
184
+ console.log(chalk.yellow(" No valid repos found — skipping global constitution."));
185
+ return;
186
+ }
187
+
188
+ const prompt = buildGlobalConstitutionPrompt(summaries);
189
+ let globalConstitution: string;
190
+ try {
191
+ globalConstitution = await provider.generate(prompt, globalConstitutionSystemPrompt);
192
+ } catch (err) {
193
+ console.error(chalk.red(` ✘ Failed to generate global constitution: ${(err as Error).message}`));
194
+ return;
195
+ }
196
+
197
+ const saved = await saveGlobalConstitution(globalConstitution, currentDir);
198
+ console.log(chalk.green(` ✔ Global constitution saved: ${saved}`));
199
+ console.log(chalk.gray(" Project constitutions will be merged with this at runtime."));
200
+
201
+ // Preview
202
+ const lines = globalConstitution.split("\n");
203
+ console.log(chalk.bold("\n Preview:"));
204
+ console.log(chalk.gray(lines.slice(0, 10).join("\n")));
205
+ if (lines.length > 10) {
206
+ console.log(chalk.gray(` ... (${lines.length} lines total)`));
207
+ }
208
+ }
209
+
210
+ // ─── Command: init ───────────────────────────────────────────────────────────
20
211
 
21
212
  export function registerInit(program: Command): void {
22
213
  program
23
214
  .command("init")
24
- .description(`Analyze codebase and generate Project Constitution (${CONSTITUTION_FILE})`)
215
+ .description("Setup workspace: register repos, generate constitutions")
25
216
  .option(
26
217
  "--provider <name>",
27
218
  `AI provider (${SUPPORTED_PROVIDERS.join("|")})`,
@@ -29,13 +220,10 @@ export function registerInit(program: Command): void {
29
220
  )
30
221
  .option("--model <name>", "Model name")
31
222
  .option("-k, --key <apiKey>", "API key")
32
- .option("--force", "Overwrite existing constitution")
33
- .option(
34
- "--global",
35
- `Generate a Global Constitution (~/${GLOBAL_CONSTITUTION_FILE}) instead of a project-level one`
36
- )
37
- .option("--consolidate", "Consolidate §9 accumulated lessons into §1–§8 core rules (prune & rebase)")
223
+ .option("--force", "Overwrite existing constitutions")
224
+ .option("--consolidate", "Consolidate §9 accumulated lessons into §1–§8 core rules")
38
225
  .option("--dry-run", "Preview consolidation result without writing (use with --consolidate)")
226
+ .option("--add-repo", "Add a new repo to the registered list")
39
227
  .action(async (opts) => {
40
228
  const currentDir = process.cwd();
41
229
  const config = await loadConfig(currentDir);
@@ -68,123 +256,138 @@ export function registerInit(program: Command): void {
68
256
  return;
69
257
  }
70
258
 
71
- // ── Global constitution mode ───────────────────────────────────────────
72
- if (opts.global) {
73
- const existing = await loadGlobalConstitution([currentDir]);
74
- if (existing && !opts.force) {
75
- console.log(chalk.yellow(`\n Global constitution already exists at: ${existing.source}`));
76
- console.log(chalk.gray(" Use --force to overwrite it."));
77
- return;
259
+ // ── Add-repo shortcut ──────────────────────────────────────────────────
260
+ if (opts.addRepo) {
261
+ console.log(chalk.blue("\n─── Register New Repo ──────────────────────────"));
262
+ const role = await promptRepoRole();
263
+ const repoPath = await promptRepoPath(role);
264
+ const entry = await registerSingleRepo(repoPath, provider, role);
265
+ console.log(chalk.green(`\n ✔ Repo registered: ${entry.name} (${entry.type} / ${entry.role})`));
266
+ console.log(chalk.gray(` Saved to: ${REPO_STORE_FILE}`));
267
+
268
+ // Ask if user wants to update global constitution
269
+ const updateGlobal = await confirm({
270
+ message: "Update global constitution with this repo's context?",
271
+ default: true,
272
+ });
273
+ if (updateGlobal) {
274
+ const allRepos = await getRegisteredRepos();
275
+ await generateGlobalConstitution(provider, allRepos, currentDir);
78
276
  }
277
+ return;
278
+ }
79
279
 
80
- console.log(chalk.blue("\n─── Generating Global Constitution ──────────────"));
81
- console.log(chalk.gray(` Provider: ${providerName}/${modelName}`));
82
-
83
- // ── Build per-project summaries ────────────────────────────────────
84
- const projectSummaries: Array<{ name: string; summary: string }> = [];
85
- const index = await loadIndex(currentDir);
86
-
87
- if (index && index.projects.length > 0) {
88
- const active = index.projects.filter((p: ProjectEntry) => !p.missing);
89
- console.log(chalk.gray(` Found project index: ${active.length} project(s) — reading constitutions...`));
90
-
91
- for (const entry of active) {
92
- const absPath = path.join(currentDir, entry.path);
93
- const lines: string[] = [
94
- `Type: ${entry.type} (${entry.role})`,
95
- `Tech stack: ${entry.techStack.join(", ") || "unknown"}`,
96
- ];
97
-
98
- // Include §1–§6 of project constitution if available (skip §9 lessons)
99
- if (entry.hasConstitution) {
100
- try {
101
- const constitutionPath = path.join(absPath, CONSTITUTION_FILE);
102
- const raw = await fs.readFile(constitutionPath, "utf-8");
103
- // Take up to first 2000 chars (covers §1–§6 without §9 noise)
104
- const excerpt = raw.slice(0, 2000);
105
- lines.push("", "Constitution excerpt:", excerpt);
106
- } catch { /* skip if unreadable */ }
107
- }
280
+ // ── Full init flow ─────────────────────────────────────────────────────
281
+ console.log(chalk.blue("\n" + "─".repeat(52)));
282
+ console.log(chalk.bold(" ai-spec init — Workspace Setup"));
283
+ console.log(chalk.blue("─".repeat(52)));
284
+ console.log(chalk.gray(` Provider: ${providerName}/${modelName}\n`));
108
285
 
109
- projectSummaries.push({ name: entry.name, summary: lines.join("\n") });
110
- }
111
- } else {
112
- // No index — fall back to scanning just the current directory
113
- console.log(chalk.yellow(" No project index found. Run `ai-spec scan` first for better results."));
114
- console.log(chalk.gray(" Falling back: scanning current directory only..."));
115
- const loader = new ContextLoader(currentDir);
116
- const ctx = await loader.loadProjectContext();
117
- projectSummaries.push({
118
- name: path.basename(currentDir),
119
- summary: [
120
- `Tech stack: ${ctx.techStack.join(", ") || "unknown"}`,
121
- `Dependencies: ${ctx.dependencies.slice(0, 20).join(", ")}`,
122
- ].join("\n"),
123
- });
124
- }
286
+ const existingRepos = await getRegisteredRepos();
125
287
 
126
- console.log(chalk.gray(` Generating from ${projectSummaries.length} project(s)...`));
127
- const prompt = buildGlobalConstitutionPrompt(projectSummaries);
128
- let globalConstitution: string;
129
- try {
130
- globalConstitution = await provider.generate(prompt, globalConstitutionSystemPrompt);
131
- } catch (err) {
132
- console.error(chalk.red(" ✘ Failed to generate global constitution:"), err);
133
- process.exit(1);
288
+ // ── Step 1: Show existing repos if any ─────────────────────────────────
289
+ if (existingRepos.length > 0) {
290
+ console.log(chalk.cyan(" Registered repos:"));
291
+ for (const r of existingRepos) {
292
+ const constitutionIcon = r.hasConstitution ? chalk.green("✔") : chalk.gray("○");
293
+ console.log(chalk.gray(` ${constitutionIcon} ${r.name} (${r.type} / ${r.role}) → ${r.path}`));
134
294
  }
295
+ console.log();
296
+ }
297
+
298
+ // ── Step 2: Register repos ─────────────────────────────────────────────
299
+ const action = existingRepos.length > 0
300
+ ? await select({
301
+ message: "What would you like to do?",
302
+ choices: [
303
+ { name: "Add new repo(s)", value: "add" as const },
304
+ { name: "Re-generate constitutions for existing repos", value: "regen" as const },
305
+ { name: "Skip — proceed to global constitution", value: "skip" as const },
306
+ ],
307
+ })
308
+ : "add" as const;
309
+
310
+ const newRepos: RegisteredRepo[] = [];
135
311
 
136
- const saved = await saveGlobalConstitution(globalConstitution, currentDir);
137
- console.log(chalk.green(`\n ✔ Global constitution saved: ${saved}`));
138
- console.log(chalk.gray(" This will be automatically merged into all project constitutions in this workspace."));
139
- console.log(chalk.gray(" Project-level rules always override global rules.\n"));
140
- console.log(chalk.bold(" Preview:"));
141
- console.log(chalk.gray(globalConstitution.split("\n").slice(0, 12).join("\n")));
142
- if (globalConstitution.split("\n").length > 12) {
143
- console.log(chalk.gray(` ... (${globalConstitution.split("\n").length} lines total)`));
312
+ if (action === "add") {
313
+ let addMore = true;
314
+ while (addMore) {
315
+ console.log(chalk.blue(`\n ── Register Repo #${existingRepos.length + newRepos.length + 1} ──`));
316
+ const role = await promptRepoRole();
317
+ const repoPath = await promptRepoPath(role);
318
+
319
+ // Check if already registered
320
+ const alreadyRegistered = [...existingRepos, ...newRepos].find((r) => r.path === repoPath);
321
+ if (alreadyRegistered) {
322
+ console.log(chalk.yellow(` Already registered: ${alreadyRegistered.name}`));
323
+ } else {
324
+ const entry = await registerSingleRepo(repoPath, provider, role);
325
+ newRepos.push(entry);
326
+ console.log(chalk.green(` ✔ Registered: ${entry.name}`));
327
+ }
328
+
329
+ addMore = await confirm({
330
+ message: "Add another repo?",
331
+ default: false,
332
+ });
144
333
  }
145
- return;
146
334
  }
147
335
 
148
- // ── Project constitution mode (default) ───────────────────────────────
149
- const constitutionPath = path.join(currentDir, CONSTITUTION_FILE);
336
+ if (action === "regen") {
337
+ console.log(chalk.blue("\n Re-generating project constitutions..."));
338
+ for (const repo of existingRepos) {
339
+ if (!(await fs.pathExists(repo.path))) {
340
+ console.log(chalk.yellow(` ⚠ ${repo.name}: path not found — skipping`));
341
+ continue;
342
+ }
150
343
 
151
- if (!opts.force && (await fs.pathExists(constitutionPath))) {
152
- console.log(chalk.yellow(`\n ${CONSTITUTION_FILE} already exists.`));
153
- console.log(chalk.gray(" Use --force to overwrite it."));
154
- console.log(chalk.gray(` Or edit it directly: ${constitutionPath}`));
155
- return;
156
- }
344
+ const constitutionPath = path.join(repo.path, CONSTITUTION_FILE);
345
+ if ((await fs.pathExists(constitutionPath)) && !opts.force) {
346
+ console.log(chalk.gray(` ${repo.name}: constitution exists (use --force to overwrite)`));
347
+ continue;
348
+ }
157
349
 
158
- console.log(chalk.blue("\n─── Generating Project Constitution ─────────────"));
159
- console.log(chalk.gray(` Provider: ${providerName}/${modelName}`));
160
- console.log(chalk.gray(" Analyzing codebase..."));
350
+ console.log(chalk.blue(` ${repo.name}: generating constitution...`));
351
+ try {
352
+ const gen = new ConstitutionGenerator(provider);
353
+ const content = await gen.generate(repo.path);
354
+ await gen.saveConstitution(repo.path, content);
355
+ console.log(chalk.green(` ✔ ${repo.name}: constitution saved`));
356
+ } catch (err) {
357
+ console.log(chalk.yellow(` ⚠ ${repo.name}: failed — ${(err as Error).message}`));
358
+ }
359
+ }
360
+ }
161
361
 
162
- const generator = new ConstitutionGenerator(provider);
362
+ // ── Step 3: Generate/update global constitution ────────────────────────
363
+ const allRepos = await getRegisteredRepos();
163
364
 
164
- let constitution: string;
165
- try {
166
- constitution = await generator.generate(currentDir);
167
- } catch (err) {
168
- console.error(chalk.red(" ✘ Failed to generate constitution:"), err);
169
- process.exit(1);
365
+ if (allRepos.length === 0) {
366
+ console.log(chalk.yellow("\n No repos registered. Run `ai-spec init` again to add repos."));
367
+ return;
170
368
  }
171
369
 
172
- const saved = await generator.saveConstitution(currentDir, constitution);
370
+ const existingGlobal = await loadGlobalConstitution([currentDir]);
371
+ const shouldGenerateGlobal = !existingGlobal || opts.force || newRepos.length > 0
372
+ ? true
373
+ : await confirm({
374
+ message: "Global constitution exists. Re-generate it?",
375
+ default: false,
376
+ });
173
377
 
174
- const globalResult = await loadGlobalConstitution([path.dirname(currentDir)]);
175
- if (globalResult) {
176
- console.log(chalk.cyan(`\n ℹ Global constitution detected: ${globalResult.source}`));
177
- console.log(chalk.gray(" It will be merged with this project constitution at runtime."));
178
- console.log(chalk.gray(" Project rules take priority over global rules."));
378
+ if (shouldGenerateGlobal) {
379
+ await generateGlobalConstitution(provider, allRepos, currentDir);
179
380
  }
180
381
 
181
- console.log(chalk.green(`\n ✔ Constitution saved: ${saved}`));
182
- console.log(chalk.gray(" This file will be automatically used in all future `ai-spec create` runs."));
183
- console.log(chalk.gray(" Edit it to add custom rules or red lines for your project.\n"));
184
- console.log(chalk.bold(" Preview:"));
185
- console.log(chalk.gray(constitution.split("\n").slice(0, 15).join("\n")));
186
- if (constitution.split("\n").length > 15) {
187
- console.log(chalk.gray(` ... (${constitution.split("\n").length} lines total)`));
382
+ // ── Done ───────────────────────────────────────────────────────────────
383
+ console.log(chalk.bold.green("\n✔ Init complete!"));
384
+ console.log(chalk.gray(` Repos registered: ${allRepos.length}`));
385
+ for (const r of allRepos) {
386
+ const icon = r.hasConstitution ? chalk.green("") : chalk.gray("");
387
+ console.log(chalk.gray(` ${icon} ${r.name} (${r.type}/${r.role})`));
188
388
  }
389
+ console.log(chalk.gray(`\n Repo store: ${REPO_STORE_FILE}`));
390
+ console.log(chalk.gray(` Next step: ai-spec create "your feature idea"`));
391
+ process.exit(0);
189
392
  });
190
393
  }
@@ -1,6 +1,4 @@
1
1
  import { Command } from "commander";
2
- import * as path from "path";
3
- import * as fs from "fs-extra";
4
2
  import chalk from "chalk";
5
3
  import { input, select, confirm } from "@inquirer/prompts";
6
4
  import {
@@ -8,7 +6,7 @@ import {
8
6
  ENV_KEY_MAP,
9
7
  PROVIDER_CATALOG,
10
8
  } from "../../core/spec-generator";
11
- import { AiSpecConfig, CONFIG_FILE, loadConfig } from "../utils";
9
+ import { AiSpecGlobalConfig, GLOBAL_CONFIG_FILE, loadGlobalConfig, saveGlobalConfig } from "../utils";
12
10
 
13
11
  export function registerModel(program: Command): void {
14
12
  program
@@ -16,9 +14,6 @@ export function registerModel(program: Command): void {
16
14
  .description("Interactively switch the active AI provider/model and save to .ai-spec.json")
17
15
  .option("--list", "List all available providers and models")
18
16
  .action(async (opts) => {
19
- const currentDir = process.cwd();
20
- const configPath = path.join(currentDir, CONFIG_FILE);
21
-
22
17
  // ── --list ──────────────────────────────────────────────────────────────
23
18
  if (opts.list) {
24
19
  console.log(chalk.bold("\nAvailable providers & models:\n"));
@@ -37,9 +32,10 @@ export function registerModel(program: Command): void {
37
32
  return;
38
33
  }
39
34
 
40
- const existing: AiSpecConfig = await loadConfig(currentDir);
35
+ const existing: AiSpecGlobalConfig = await loadGlobalConfig();
41
36
 
42
37
  console.log(chalk.blue("\n─── Model Switcher ─────────────────────────────"));
38
+ console.log(chalk.gray(` Config: ${GLOBAL_CONFIG_FILE}`));
43
39
  if (Object.keys(existing).length > 0) {
44
40
  console.log(
45
41
  chalk.gray(
@@ -92,7 +88,7 @@ export function registerModel(program: Command): void {
92
88
  return { provider: providerKey, model: chosenModel };
93
89
  }
94
90
 
95
- const updated: AiSpecConfig = { ...existing };
91
+ const updated: AiSpecGlobalConfig = { ...existing };
96
92
 
97
93
  if (target === "spec" || target === "both") {
98
94
  const { provider, model } = await pickProviderAndModel("Spec");
@@ -134,14 +130,14 @@ export function registerModel(program: Command): void {
134
130
  );
135
131
  }
136
132
 
137
- const ok = await confirm({ message: "Save to .ai-spec.json?", default: true });
133
+ const ok = await confirm({ message: `Save to ${GLOBAL_CONFIG_FILE}?`, default: true });
138
134
  if (!ok) {
139
135
  console.log(chalk.gray(" Cancelled."));
140
136
  return;
141
137
  }
142
138
 
143
- await fs.writeJson(configPath, updated, { spaces: 2 });
144
- console.log(chalk.green(`\n ✔ Saved to ${configPath}`));
139
+ await saveGlobalConfig(updated);
140
+ console.log(chalk.green(`\n ✔ Saved to ${GLOBAL_CONFIG_FILE}`));
145
141
 
146
142
  const providerToCheck = updated.provider ?? "gemini";
147
143
  const envKey = ENV_KEY_MAP[providerToCheck];
package/cli/index.ts CHANGED
@@ -27,7 +27,7 @@ const program = new Command();
27
27
  program
28
28
  .name("ai-spec")
29
29
  .description("AI-driven Development Orchestrator — spec, generate, review")
30
- .version("0.14.1");
30
+ .version(require("../package.json").version);
31
31
 
32
32
  registerCreate(program);
33
33
  registerReview(program);
package/cli/utils.ts CHANGED
@@ -1,19 +1,25 @@
1
1
  import * as path from "path";
2
2
  import * as fs from "fs-extra";
3
+ import * as os from "os";
3
4
  import chalk from "chalk";
4
5
  import { input, select } from "@inquirer/prompts";
5
6
  import { CodeGenMode } from "../core/code-generator";
6
- import { ENV_KEY_MAP } from "../core/spec-generator";
7
+ import { ENV_KEY_MAP, PROVIDER_CATALOG } from "../core/spec-generator";
7
8
  import { getSavedKey, saveKey, KEY_STORE_FILE } from "../core/key-store";
8
9
 
9
10
  // ─── Config ───────────────────────────────────────────────────────────────────
10
11
 
11
- export interface AiSpecConfig {
12
+ /** User-level preferences (stored in ~/.ai-spec-config.json) */
13
+ export interface AiSpecGlobalConfig {
12
14
  provider?: string;
13
15
  model?: string;
14
16
  codegen?: CodeGenMode;
15
17
  codegenProvider?: string;
16
18
  codegenModel?: string;
19
+ }
20
+
21
+ /** Full merged config (global + project-level overrides) */
22
+ export interface AiSpecConfig extends AiSpecGlobalConfig {
17
23
  /** Minimum overall spec score (1-10) required to pass Approval Gate. 0 = disabled (default). */
18
24
  minSpecScore?: number;
19
25
  /** Minimum harness score (1-10) required for pipeline success. 0 = disabled (default). */
@@ -22,16 +28,70 @@ export interface AiSpecConfig {
22
28
  maxErrorCycles?: number;
23
29
  /** §9 lesson count threshold for auto-consolidation (default: 12). */
24
30
  autoConsolidateThreshold?: number;
31
+
32
+ // ── Directory & file overrides ─────────────────────────────────────────────
33
+ /** Run log directory (default: ".ai-spec-logs") */
34
+ logDir?: string;
35
+ /** VCR recording directory (default: ".ai-spec-vcr") */
36
+ vcrDir?: string;
37
+ /** File backup directory (default: ".ai-spec-backup") */
38
+ backupDir?: string;
39
+ /** Review history file (default: ".ai-spec-reviews.json") */
40
+ reviewHistoryFile?: string;
41
+
42
+ // ── URL overrides ──────────────────────────────────────────────────────────
43
+ /** Default server URL for OpenAPI export (default: "http://localhost:3000") */
44
+ openApiServerUrl?: string;
45
+
46
+ // ── Numeric limits ─────────────────────────────────────────────────────────
47
+ /** Max chars captured from build/test/lint command output (default: 30000) */
48
+ maxCommandOutputChars?: number;
49
+ /** Max chars of source file sent to AI for auto-fix (default: 60000) */
50
+ maxFixFileChars?: number;
51
+ /** Max DSL extraction retries (default: 2) */
52
+ dslMaxRetries?: number;
53
+ /** Max constitution chars in codegen prompt (default: 4000) */
54
+ maxConstitutionChars?: number;
55
+ /** Per-provider token budget overrides (e.g. { "gemini": 900000, "claude": 180000 }) */
56
+ providerTokenBudgets?: Record<string, number>;
25
57
  }
26
58
 
27
59
  export const CONFIG_FILE = ".ai-spec.json";
60
+ export const GLOBAL_CONFIG_FILE = path.join(os.homedir(), ".ai-spec-config.json");
61
+
62
+ /** Load global user-level config from ~/.ai-spec-config.json */
63
+ export async function loadGlobalConfig(): Promise<AiSpecGlobalConfig> {
64
+ try {
65
+ if (await fs.pathExists(GLOBAL_CONFIG_FILE)) {
66
+ return await fs.readJson(GLOBAL_CONFIG_FILE);
67
+ }
68
+ } catch { /* ignore */ }
69
+ return {};
70
+ }
28
71
 
72
+ /** Save global user-level config to ~/.ai-spec-config.json */
73
+ export async function saveGlobalConfig(config: AiSpecGlobalConfig): Promise<void> {
74
+ await fs.ensureFile(GLOBAL_CONFIG_FILE);
75
+ await fs.writeJson(GLOBAL_CONFIG_FILE, config, { spaces: 2 });
76
+ }
77
+
78
+ /**
79
+ * Load merged config: global (baseline) + project-level (override).
80
+ * Provider/model from global, project-specific settings from local .ai-spec.json.
81
+ */
29
82
  export async function loadConfig(dir: string): Promise<AiSpecConfig> {
83
+ const globalConfig = await loadGlobalConfig();
84
+
85
+ let localConfig: AiSpecConfig = {};
30
86
  const p = path.join(dir, CONFIG_FILE);
31
87
  if (await fs.pathExists(p)) {
32
- return fs.readJson(p);
88
+ try {
89
+ localConfig = await fs.readJson(p);
90
+ } catch { /* ignore */ }
33
91
  }
34
- return {};
92
+
93
+ // Local overrides global
94
+ return { ...globalConfig, ...localConfig };
35
95
  }
36
96
 
37
97
  // ─── API Key Resolution ───────────────────────────────────────────────────────
@@ -45,6 +105,14 @@ export async function resolveApiKey(
45
105
  const envVar = ENV_KEY_MAP[providerName];
46
106
  if (envVar && process.env[envVar]) return process.env[envVar]!;
47
107
 
108
+ // Check fallback env vars (e.g. MiMo reads ANTHROPIC_AUTH_TOKEN from token-plan)
109
+ const meta = PROVIDER_CATALOG[providerName];
110
+ if (meta?.fallbackEnvKeys) {
111
+ for (const key of meta.fallbackEnvKeys) {
112
+ if (process.env[key]) return process.env[key]!;
113
+ }
114
+ }
115
+
48
116
  const savedKey = await getSavedKey(providerName);
49
117
  if (savedKey) {
50
118
  const masked = savedKey.slice(0, 6) + "..." + savedKey.slice(-4);