Skip to content

Commit 260371e

Browse files
author
Marvin Zhang
committed
feat(mcp-tools): implement link and unlink tools for managing spec relationships
1 parent c244a47 commit 260371e

File tree

7 files changed

+563
-36
lines changed

7 files changed

+563
-36
lines changed

AGENTS.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,11 @@ Lightweight spec methodology for AI-powered development.
3232
| View spec | `view` | `lean-spec view <spec>` |
3333
| Create spec | `create` | `lean-spec create <name>` |
3434
| Update spec | `update` | `lean-spec update <spec> --status <status>` |
35-
| Link specs | `create --related` | `lean-spec link <spec> --related <other>` |
36-
| Unlink specs | ❌ CLI only | `lean-spec unlink <spec> --related <other>` |
35+
| Link specs | `link` | `lean-spec link <spec> --related <other>` |
36+
| Unlink specs | `unlink` | `lean-spec unlink <spec> --related <other>` |
3737
| Dependencies | `deps` | `lean-spec deps <spec>` |
3838
| Token count | `tokens` | `lean-spec tokens <spec>` |
3939

40-
**⚠️ Link/Unlink**: Use `--related` or `--depends-on` params in `create` tool. For existing specs, use CLI fallback. (See spec 129 for MCP tool tracking)
41-
4240
**Local Development:** Use `node bin/lean-spec.js <command>` instead of `npx lean-spec`. Build first with `pnpm build`.
4341

4442
## ⚠️ Core Rules

packages/cli/src/commands/migrate.ts

Lines changed: 265 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { loadConfig } from '../config.js';
66
export interface MigrationOptions {
77
inputPath: string;
88
aiProvider?: 'copilot' | 'claude' | 'gemini';
9+
auto?: boolean;
910
dryRun?: boolean;
1011
batchSize?: number;
1112
skipValidation?: boolean;
@@ -16,8 +17,11 @@ export interface DocumentInfo {
1617
path: string;
1718
name: string;
1819
size: number;
20+
format?: 'spec-kit' | 'openspec' | 'adr' | 'generic';
1921
}
2022

23+
export type SourceFormat = 'spec-kit' | 'openspec' | 'generic';
24+
2125
export function migrateCommand(): Command;
2226
export function migrateCommand(inputPath: string, options?: Partial<MigrationOptions>): Promise<void>;
2327
export function migrateCommand(inputPath?: string, options: Partial<MigrationOptions> = {}): Command | Promise<void> {
@@ -26,14 +30,16 @@ export function migrateCommand(inputPath?: string, options: Partial<MigrationOpt
2630
}
2731

2832
return new Command('migrate')
29-
.description('Migrate specs from other SDD tools (ADR, RFC, OpenSpec, spec-kit, etc.)')
33+
.description('Migrate specs from other SDD tools (OpenSpec, spec-kit, etc.)')
3034
.argument('<input-path>', 'Path to directory containing specs to migrate')
35+
.option('--auto', 'Automatic migration: detect format, restructure, and backfill in one shot')
3136
.option('--with <provider>', 'AI-assisted migration (copilot, claude, gemini)')
3237
.option('--dry-run', 'Preview without making changes')
3338
.option('--batch-size <n>', 'Process N docs at a time', parseInt)
3439
.option('--skip-validation', "Don't validate after migration")
3540
.option('--backfill', 'Auto-run backfill after migration')
3641
.action(async (target: string, opts: {
42+
auto?: boolean;
3743
with?: string;
3844
dryRun?: boolean;
3945
batchSize?: number;
@@ -45,6 +51,7 @@ export function migrateCommand(inputPath?: string, options: Partial<MigrationOpt
4551
process.exit(1);
4652
}
4753
await migrateSpecs(target, {
54+
auto: opts.auto,
4855
aiProvider: opts.with as 'copilot' | 'claude' | 'gemini' | undefined,
4956
dryRun: opts.dryRun,
5057
batchSize: opts.batchSize,
@@ -84,12 +91,22 @@ export async function migrateSpecs(inputPath: string, options: Partial<Migration
8491

8592
console.log(`\x1b[32m✓\x1b[0m Found ${documents.length} document${documents.length === 1 ? '' : 's'}\n`);
8693

94+
// Detect source format
95+
const format = await detectSourceFormat(inputPath, documents);
96+
console.log(`\x1b[36mDetected format:\x1b[0m ${format}\n`);
97+
98+
// Auto mode: one-shot migration
99+
if (options.auto) {
100+
await migrateAuto(inputPath, documents, format, config, options);
101+
return;
102+
}
103+
87104
// If AI provider specified, verify and execute
88105
if (options.aiProvider) {
89106
await migrateWithAI(inputPath, documents, options as MigrationOptions);
90107
} else {
91108
// Default: Output manual migration instructions
92-
await outputManualInstructions(inputPath, documents, config);
109+
await outputManualInstructions(inputPath, documents, config, format);
93110
}
94111
}
95112

@@ -128,13 +145,253 @@ export async function scanDocuments(dirPath: string): Promise<DocumentInfo[]> {
128145
return documents;
129146
}
130147

148+
/**
149+
* Detect source format based on directory structure and file patterns
150+
*/
151+
export async function detectSourceFormat(inputPath: string, documents: DocumentInfo[]): Promise<SourceFormat> {
152+
// Check for spec-kit pattern: .specify/specs/ with spec.md files
153+
const hasSpecKit = documents.some(d =>
154+
d.path.includes('.specify') || d.name === 'spec.md'
155+
);
156+
if (hasSpecKit) {
157+
return 'spec-kit';
158+
}
159+
160+
// Check for OpenSpec pattern: openspec/ directory with specs/ and changes/
161+
const hasOpenSpec = documents.some(d => d.path.includes('openspec/'));
162+
if (hasOpenSpec) {
163+
return 'openspec';
164+
}
165+
166+
// Default to generic markdown
167+
return 'generic';
168+
}
169+
170+
/**
171+
* Auto migration - one-shot migration with format detection
172+
*/
173+
async function migrateAuto(
174+
inputPath: string,
175+
documents: DocumentInfo[],
176+
format: SourceFormat,
177+
config: any,
178+
options: Partial<MigrationOptions>
179+
): Promise<void> {
180+
const specsDir = path.join(process.cwd(), config.specsDir || 'specs');
181+
const startTime = Date.now();
182+
183+
console.log('═'.repeat(70));
184+
console.log('\x1b[1m\x1b[36m🚀 Auto Migration\x1b[0m');
185+
console.log('═'.repeat(70));
186+
console.log();
187+
188+
if (options.dryRun) {
189+
console.log('\x1b[33m⚠️ DRY RUN - No changes will be made\x1b[0m\n');
190+
}
191+
192+
// Ensure specs directory exists
193+
if (!options.dryRun) {
194+
await fs.mkdir(specsDir, { recursive: true });
195+
}
196+
197+
let migratedCount = 0;
198+
let skippedCount = 0;
199+
200+
// Get existing spec numbers for sequencing
201+
let nextSeq = 1;
202+
try {
203+
const existingSpecs = await fs.readdir(specsDir);
204+
const seqNumbers = existingSpecs
205+
.map(name => {
206+
const match = name.match(/^(\d+)-/);
207+
return match ? parseInt(match[1], 10) : 0;
208+
})
209+
.filter(n => n > 0);
210+
if (seqNumbers.length > 0) {
211+
nextSeq = Math.max(...seqNumbers) + 1;
212+
}
213+
} catch {
214+
// specs dir doesn't exist yet
215+
}
216+
217+
// Batch operations based on format
218+
if (format === 'spec-kit') {
219+
// spec-kit: Folders already structured, just rename spec.md -> README.md
220+
console.log('\x1b[36mMigrating spec-kit format...\x1b[0m\n');
221+
222+
// Find all spec.md files and their parent directories
223+
const specMdFiles = documents.filter(d => d.name === 'spec.md');
224+
225+
for (const doc of specMdFiles) {
226+
const sourceDir = path.dirname(doc.path);
227+
const dirName = path.basename(sourceDir);
228+
229+
// Check if already has sequence number
230+
const hasSeq = /^\d{3}-/.test(dirName);
231+
const targetDirName = hasSeq ? dirName : `${String(nextSeq).padStart(3, '0')}-${dirName}`;
232+
const targetDir = path.join(specsDir, targetDirName);
233+
234+
if (!options.dryRun) {
235+
// Copy entire directory
236+
await copyDirectory(sourceDir, targetDir);
237+
238+
// Rename spec.md to README.md
239+
const oldPath = path.join(targetDir, 'spec.md');
240+
const newPath = path.join(targetDir, 'README.md');
241+
try {
242+
await fs.rename(oldPath, newPath);
243+
} catch {
244+
// spec.md might not exist if already README.md
245+
}
246+
}
247+
248+
console.log(` \x1b[32m✓\x1b[0m ${dirName}${targetDirName}/`);
249+
migratedCount++;
250+
if (!hasSeq) nextSeq++;
251+
}
252+
} else if (format === 'openspec') {
253+
// OpenSpec: Merge specs/ and changes/archive/, restructure
254+
console.log('\x1b[36mMigrating OpenSpec format...\x1b[0m\n');
255+
256+
// Group documents by their containing folder
257+
const folders = new Map<string, DocumentInfo[]>();
258+
for (const doc of documents) {
259+
const parentDir = path.dirname(doc.path);
260+
const folderName = path.basename(parentDir);
261+
if (!folders.has(folderName)) {
262+
folders.set(folderName, []);
263+
}
264+
folders.get(folderName)!.push(doc);
265+
}
266+
267+
for (const [folderName, docs] of folders) {
268+
// Skip if it's a container folder
269+
if (['specs', 'archive', 'changes', 'openspec'].includes(folderName)) {
270+
continue;
271+
}
272+
273+
const targetDirName = `${String(nextSeq).padStart(3, '0')}-${folderName}`;
274+
const targetDir = path.join(specsDir, targetDirName);
275+
276+
if (!options.dryRun) {
277+
await fs.mkdir(targetDir, { recursive: true });
278+
279+
for (const doc of docs) {
280+
const targetName = doc.name === 'spec.md' ? 'README.md' : doc.name;
281+
const targetPath = path.join(targetDir, targetName);
282+
await fs.copyFile(doc.path, targetPath);
283+
}
284+
}
285+
286+
console.log(` \x1b[32m✓\x1b[0m ${folderName}${targetDirName}/`);
287+
migratedCount++;
288+
nextSeq++;
289+
}
290+
} else {
291+
// Generic: Create folder for each markdown file
292+
console.log('\x1b[36mMigrating generic markdown files...\x1b[0m\n');
293+
294+
for (const doc of documents) {
295+
// Extract name from filename (remove extension and leading numbers)
296+
const baseName = path.basename(doc.name, path.extname(doc.name))
297+
.replace(/^\d+-/, '') // Remove leading numbers
298+
.replace(/^[_-]+/, '') // Remove leading separators
299+
.toLowerCase()
300+
.replace(/[^a-z0-9]+/g, '-') // Normalize to kebab-case
301+
.replace(/-+$/, ''); // Remove trailing dashes
302+
303+
if (!baseName) {
304+
console.log(` \x1b[33m⚠\x1b[0m Skipped: ${doc.name} (invalid name)`);
305+
skippedCount++;
306+
continue;
307+
}
308+
309+
const targetDirName = `${String(nextSeq).padStart(3, '0')}-${baseName}`;
310+
const targetDir = path.join(specsDir, targetDirName);
311+
const targetPath = path.join(targetDir, 'README.md');
312+
313+
if (!options.dryRun) {
314+
await fs.mkdir(targetDir, { recursive: true });
315+
await fs.copyFile(doc.path, targetPath);
316+
}
317+
318+
console.log(` \x1b[32m✓\x1b[0m ${doc.name}${targetDirName}/README.md`);
319+
migratedCount++;
320+
nextSeq++;
321+
}
322+
}
323+
324+
console.log();
325+
326+
// Run backfill if requested or in auto mode
327+
if ((options.backfill || options.auto) && !options.dryRun) {
328+
console.log('\x1b[36mRunning backfill...\x1b[0m');
329+
try {
330+
const { backfillCommand } = await import('./backfill.js');
331+
// Create command and run with options
332+
const cmd = backfillCommand();
333+
await cmd.parseAsync(['node', 'lean-spec', '--all', '--assignee'], { from: 'user' });
334+
console.log('\x1b[32m✓\x1b[0m Backfill complete\n');
335+
} catch (error) {
336+
console.log('\x1b[33m⚠\x1b[0m Backfill failed, run manually: lean-spec backfill --all\n');
337+
}
338+
}
339+
340+
// Run validation if not skipped
341+
if (!options.skipValidation && !options.dryRun) {
342+
console.log('\x1b[36mValidating...\x1b[0m');
343+
try {
344+
const { validateSpecs } = await import('./validate.js');
345+
await validateSpecs({});
346+
console.log('\x1b[32m✓\x1b[0m Validation complete\n');
347+
} catch {
348+
console.log('\x1b[33m⚠\x1b[0m Validation had issues, run: lean-spec validate\n');
349+
}
350+
}
351+
352+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
353+
354+
console.log('═'.repeat(70));
355+
console.log(`\x1b[32m✓ Migration complete!\x1b[0m`);
356+
console.log(` Migrated: ${migratedCount} specs`);
357+
if (skippedCount > 0) {
358+
console.log(` Skipped: ${skippedCount} files`);
359+
}
360+
console.log(` Time: ${elapsed}s`);
361+
console.log('═'.repeat(70));
362+
console.log();
363+
console.log('Next steps:');
364+
console.log(' lean-spec board # View your specs');
365+
console.log(' lean-spec validate # Check for issues');
366+
}
367+
368+
/**
369+
* Copy directory recursively
370+
*/
371+
async function copyDirectory(src: string, dest: string): Promise<void> {
372+
await fs.mkdir(dest, { recursive: true });
373+
const entries = await fs.readdir(src, { withFileTypes: true });
374+
375+
for (const entry of entries) {
376+
const srcPath = path.join(src, entry.name);
377+
const destPath = path.join(dest, entry.name);
378+
379+
if (entry.isDirectory()) {
380+
await copyDirectory(srcPath, destPath);
381+
} else {
382+
await fs.copyFile(srcPath, destPath);
383+
}
384+
}
385+
}
386+
131387
/**
132388
* Output manual migration instructions (default mode)
133389
*/
134390
async function outputManualInstructions(
135391
inputPath: string,
136392
documents: DocumentInfo[],
137-
config: any
393+
config: any,
394+
format: SourceFormat
138395
): Promise<void> {
139396
const specsDir = config.specsDir || 'specs';
140397

@@ -144,6 +401,11 @@ async function outputManualInstructions(
144401
console.log();
145402
console.log('\x1b[1mSource Location:\x1b[0m');
146403
console.log(` ${inputPath} (${documents.length} documents found)`);
404+
console.log(` Detected format: ${format}`);
405+
console.log();
406+
console.log('\x1b[1m💡 Quick Option:\x1b[0m');
407+
console.log(` \x1b[36mlean-spec migrate ${inputPath} --auto\x1b[0m`);
408+
console.log(' This will automatically restructure and backfill in one shot.');
147409
console.log();
148410
console.log('\x1b[1mMigration Prompt:\x1b[0m');
149411
console.log(' Copy this prompt to your AI assistant (Copilot, Claude, ChatGPT, etc.):');

0 commit comments

Comments
 (0)