ai-spec-dev 0.41.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.
@@ -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);
@@ -51,6 +51,7 @@ import {
51
51
  loadVcrRecording,
52
52
  } from "../../core/vcr";
53
53
  import { printBanner } from "./helpers";
54
+ import { startSpinner, startStage } from "../../core/cli-ui";
54
55
 
55
56
  // ─── Pipeline Options ────────────────────────────────────────────────────────
56
57
 
@@ -168,7 +169,7 @@ export async function runSingleRepoPipeline(
168
169
  console.log(chalk.yellow(` ⚠ Constitution is long (${context.constitution.length.toLocaleString()} chars). Consider running: ai-spec init --consolidate`));
169
170
  }
170
171
  } else {
171
- console.log(chalk.yellow(" Constitution : not found — auto-generating..."));
172
+ const constitutionSpinner = startSpinner("Constitution not found — auto-generating...");
172
173
  try {
173
174
  const constitutionGen = new ConstitutionGenerator(
174
175
  createProvider(specProviderName, specApiKey, specModelName)
@@ -176,9 +177,9 @@ export async function runSingleRepoPipeline(
176
177
  const constitutionContent = await constitutionGen.generate(currentDir);
177
178
  await constitutionGen.saveConstitution(currentDir, constitutionContent);
178
179
  context.constitution = constitutionContent;
179
- console.log(chalk.green(` Constitution : ✔ generated and saved (.ai-spec-constitution.md)`));
180
+ constitutionSpinner.succeed("Constitution generated and saved (.ai-spec-constitution.md)");
180
181
  } catch (err) {
181
- console.log(chalk.yellow(` Constitution : ⚠ auto-generation failed (${(err as Error).message}), continuing without it.`));
182
+ constitutionSpinner.fail(`Constitution auto-generation failed (${(err as Error).message}), continuing without it.`);
182
183
  }
183
184
  }
184
185
 
@@ -214,17 +215,18 @@ export async function runSingleRepoPipeline(
214
215
  let initialTasks: import("../../core/task-generator").SpecTask[] = [];
215
216
 
216
217
  runLogger.stageStart("spec_gen", { provider: specProviderName, model: specModelName });
218
+ const specSpinner = startStage("spec_gen", `Generating spec with ${specProviderName}/${specModelName}...`);
217
219
  try {
218
220
  if (opts.skipTasks) {
219
221
  const { SpecGenerator } = await import("../../core/spec-generator");
220
222
  const generator = new SpecGenerator(specProvider);
221
223
  initialSpec = await generator.generateSpec(idea, context, architectureDecision);
222
- console.log(chalk.green("Spec generated."));
224
+ specSpinner.succeed("Spec generated.");
223
225
  } else {
224
226
  const result = await generateSpecWithTasks(specProvider, idea, context, architectureDecision);
225
227
  initialSpec = result.spec;
226
228
  initialTasks = result.tasks;
227
- console.log(chalk.green(` ✔ Spec generated.`));
229
+ specSpinner.succeed("Spec generated.");
228
230
  if (initialTasks.length > 0) {
229
231
  console.log(chalk.green(` ✔ ${initialTasks.length} tasks generated (combined call).`));
230
232
  } else {
@@ -233,8 +235,8 @@ export async function runSingleRepoPipeline(
233
235
  }
234
236
  runLogger.stageEnd("spec_gen", { taskCount: initialTasks.length });
235
237
  } catch (err) {
238
+ specSpinner.fail(`Spec generation failed: ${(err as Error).message}`);
236
239
  runLogger.stageFail("spec_gen", (err as Error).message);
237
- console.error(chalk.red(" ✘ Spec generation failed:"), err);
238
240
  process.exit(1);
239
241
  }
240
242
 
@@ -262,7 +264,9 @@ export async function runSingleRepoPipeline(
262
264
  console.log(chalk.blue("\n[3.4/6] Spec quality assessment..."));
263
265
  }
264
266
  runLogger.stageStart("spec_assess");
267
+ const assessSpinner = startStage("spec_assess", "Evaluating spec quality...");
265
268
  const assessment = await assessSpec(specProvider, finalSpec, context.constitution ?? undefined);
269
+ assessSpinner.stop();
266
270
  if (assessment) {
267
271
  runLogger.stageEnd("spec_assess", { overallScore: assessment.overallScore });
268
272
  if (!opts.auto) printSpecAssessment(assessment);
@@ -366,21 +370,24 @@ export async function runSingleRepoPipeline(
366
370
  console.log(chalk.blue("\n[DSL] Extracting structured DSL from spec..."));
367
371
  console.log(chalk.gray(` Provider: ${specProviderName}/${specModelName}`));
368
372
  runLogger.stageStart("dsl_extract");
373
+ const dslSpinner = startStage("dsl_extract", "Extracting DSL from spec...");
369
374
  try {
370
375
  const isFrontend = isFrontendDeps(context.dependencies);
371
- if (isFrontend) console.log(chalk.gray(" Frontend project detected — using ComponentSpec extractor"));
376
+ if (isFrontend) {
377
+ dslSpinner.update("🔗 Extracting DSL (frontend ComponentSpec mode)...");
378
+ }
372
379
  const dslExtractor = new DslExtractor(specProvider);
373
380
  extractedDsl = await dslExtractor.extract(finalSpec, { auto: opts.auto, isFrontend });
374
381
  if (extractedDsl) {
375
382
  runLogger.stageEnd("dsl_extract", { endpoints: extractedDsl.endpoints?.length ?? 0, models: extractedDsl.models?.length ?? 0 });
376
- console.log(chalk.green("DSL extracted and validated."));
383
+ dslSpinner.succeed("DSL extracted and validated.");
377
384
  } else {
378
385
  runLogger.stageEnd("dsl_extract", { skipped: true });
379
- console.log(chalk.yellow("DSL skipped — codegen will use Spec + Tasks only."));
386
+ dslSpinner.fail("DSL skipped — codegen will use Spec + Tasks only.");
380
387
  }
381
388
  } catch (err) {
382
389
  runLogger.stageFail("dsl_extract", (err as Error).message);
383
- console.log(chalk.yellow(`DSL extraction error: ${(err as Error).message} — continuing without DSL.`));
390
+ dslSpinner.fail(`DSL extraction error: ${(err as Error).message} — continuing without DSL.`);
384
391
  }
385
392
  }
386
393
 
@@ -590,6 +597,7 @@ export async function runSingleRepoPipeline(
590
597
  if (!opts.skipReview) {
591
598
  console.log(chalk.blue("\n[9/9] Automated code review (3-pass: architecture + implementation + impact/complexity)..."));
592
599
  runLogger.stageStart("review");
600
+ const reviewSpinner = startStage("review", "Running 3-pass code review...");
593
601
  const reviewer = new CodeReviewer(specProvider, workingDir);
594
602
  const savedSpec = await fs.readFile(specFile, "utf-8");
595
603
 
@@ -598,6 +606,7 @@ export async function runSingleRepoPipeline(
598
606
  } else {
599
607
  reviewResult = await reviewer.reviewCode(savedSpec, specFile);
600
608
  }
609
+ reviewSpinner.succeed("Code review complete.");
601
610
  runLogger.stageEnd("review");
602
611
 
603
612
  // Surface Pass 0 compliance score