Intelligent prs init Implementation Plan¶
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Redesign prs init to auto-detect existing AI instruction files and offer inline migration (static import or AI-assisted), producing modular .prs output.
Architecture: Two parallel foundation tracks (CLI utilities + importer multi-file support) converge into the enhanced init command. The init command gains a gateway prompt (migrate vs fresh start), the importer gains multi-file merge capabilities, and a thin prs migrate alias wraps the init migration path.
Tech Stack: TypeScript, Commander.js, Inquirer.js (@inquirer/prompts), Vitest, @promptscript/importer, @promptscript/formatters
Spec: docs/design/2026-03-20-intelligent-init-design.md
File Map¶
New files¶
| File | Responsibility |
|---|---|
packages/cli/src/utils/clipboard.ts | Cross-platform clipboard write (pbcopy/xclip/clip) |
packages/cli/src/utils/backup.ts | .prs-backup/<timestamp>/ creation, git repo detection |
packages/cli/src/utils/migration-prompt.ts | Kick-start prompt generation from MigrationCandidate[] |
packages/importer/src/merger.ts | Merge ScoredSection[] from multiple files: dedup, group by block |
packages/importer/src/multi-importer.ts | Batch importFile() + merger -> modular .prs file map |
packages/cli/src/commands/migrate.ts | Thin wrapper: delegates to initCommand with migrate flags |
New test files¶
| File | Tests |
|---|---|
packages/cli/src/utils/__tests__/clipboard.spec.ts | Clipboard write, fallback on missing binary |
packages/cli/src/utils/__tests__/backup.spec.ts | Backup creation, git detection, timestamp dirs |
packages/cli/src/utils/__tests__/migration-prompt.spec.ts | Prompt generation from candidates |
packages/importer/src/__tests__/merger.spec.ts | Section merge, dedup, conflict resolution per block type |
packages/importer/src/__tests__/multi-importer.spec.ts | Multi-file import, modular output, confidence report |
packages/cli/src/__tests__/init-migrate.spec.ts | Init gateway, static migration, AI-assisted flow |
packages/cli/src/__tests__/migrate-command.spec.ts | prs migrate delegation, --static, --llm, --files |
Modified files¶
| File | Changes |
|---|---|
packages/cli/src/utils/ai-tools-detector.ts | migrationCandidates: string[] -> MigrationCandidate[] with size/format/toolName |
packages/cli/src/types.ts | Add autoImport, backup to InitOptions; add MigrateOptions interface |
packages/cli/src/commands/init.ts | Gateway prompt, migrate flow, skill install on fresh start, exit code 2 |
packages/cli/src/cli.ts | Register prs migrate, add --auto-import/--backup to init, deprecate --migrate |
packages/importer/src/emitter.ts | Add emitModularFiles() for multi-file output |
packages/importer/src/index.ts | Export merger, multi-importer |
packages/cli/skills/promptscript/SKILL.md | Update CLI commands section with prs migrate |
Chunk 1: Foundation Utilities (Phase 1)¶
Task 1: Clipboard Utility¶
Files:
- Create:
packages/cli/src/utils/clipboard.ts -
Test:
packages/cli/src/utils/__tests__/clipboard.spec.ts -
Step 1: Write failing tests
// packages/cli/src/utils/__tests__/clipboard.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { copyToClipboard } from '../clipboard.js';
import { execFileSync } from 'child_process';
vi.mock('child_process', () => ({
execFileSync: vi.fn(),
}));
describe('copyToClipboard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns true on success', () => {
vi.mocked(execFileSync).mockReturnValue(Buffer.from(''));
const result = copyToClipboard('hello');
expect(result).toBe(true);
expect(execFileSync).toHaveBeenCalled();
});
it('returns false when clipboard command fails', () => {
vi.mocked(execFileSync).mockImplementation(() => {
throw new Error('command not found');
});
const result = copyToClipboard('hello');
expect(result).toBe(false);
});
it('passes text via stdin to the clipboard command', () => {
vi.mocked(execFileSync).mockReturnValue(Buffer.from(''));
copyToClipboard('test content');
expect(execFileSync).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({ input: 'test content' })
);
});
});
- Step 2: Run test to verify it fails
Run: pnpm nx test cli -- --testPathPattern clipboard Expected: FAIL -- module not found
- Step 3: Write implementation
// packages/cli/src/utils/clipboard.ts
import { execFileSync } from 'child_process';
/**
* Platform-specific clipboard commands.
* Each entry is [binary, ...args].
* Tried in order; first successful one wins.
*/
const CLIPBOARD_COMMANDS: Record<string, [string, string[]][]> = {
darwin: [['pbcopy', []]],
linux: [
['xclip', ['-selection', 'clipboard']],
['xsel', ['--clipboard', '--input']],
],
win32: [['clip', []]],
};
/**
* Copy text to the system clipboard.
* Returns true on success, false if clipboard is unavailable.
*/
export function copyToClipboard(text: string): boolean {
const commands = CLIPBOARD_COMMANDS[process.platform] ?? [];
for (const [binary, args] of commands) {
try {
execFileSync(binary, args, { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
return true;
} catch {
// Try next command
}
}
return false;
}
- Step 4: Run test to verify it passes
Run: pnpm nx test cli -- --testPathPattern clipboard Expected: PASS (3 tests)
- Step 5: Commit
git add packages/cli/src/utils/clipboard.ts packages/cli/src/utils/__tests__/clipboard.spec.ts
git commit -m "feat(cli): add cross-platform clipboard utility"
Task 2: Backup Utility¶
Files:
- Create:
packages/cli/src/utils/backup.ts -
Test:
packages/cli/src/utils/__tests__/backup.spec.ts -
Step 1: Write failing tests
// packages/cli/src/utils/__tests__/backup.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createBackup, isGitRepo } from '../backup.js';
import type { CliServices } from '../../services.js';
describe('isGitRepo', () => {
it('returns true when .git exists', () => {
const services = {
fs: { existsSync: vi.fn().mockReturnValue(true) },
} as unknown as CliServices;
expect(isGitRepo(services)).toBe(true);
expect(services.fs.existsSync).toHaveBeenCalledWith('.git');
});
it('returns false when .git does not exist', () => {
const services = {
fs: { existsSync: vi.fn().mockReturnValue(false) },
} as unknown as CliServices;
expect(isGitRepo(services)).toBe(false);
});
});
describe('createBackup', () => {
let mockServices: CliServices;
beforeEach(() => {
mockServices = {
fs: {
existsSync: vi.fn().mockReturnValue(true),
readFileSync: vi.fn().mockReturnValue('file content'),
writeFile: vi.fn().mockResolvedValue(undefined),
mkdir: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue('file content'),
readdir: vi.fn().mockResolvedValue([]),
},
prompts: {} as CliServices['prompts'],
cwd: '/mock',
} as unknown as CliServices;
});
it('creates timestamped backup directory', async () => {
const result = await createBackup(['CLAUDE.md'], mockServices);
expect(mockServices.fs.mkdir).toHaveBeenCalledWith(
expect.stringMatching(/^\.prs-backup\/\d{4}-\d{2}-\d{2}T/),
{ recursive: true }
);
expect(result.dir).toMatch(/^\.prs-backup\/\d{4}-\d{2}-\d{2}T/);
});
it('copies listed files to backup directory', async () => {
await createBackup(['CLAUDE.md', '.cursorrules'], mockServices);
expect(mockServices.fs.writeFile).toHaveBeenCalledTimes(2);
expect(mockServices.fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining('CLAUDE.md'),
'file content',
'utf-8'
);
});
it('skips files that do not exist', async () => {
vi.mocked(mockServices.fs.existsSync).mockImplementation((p: string) => p !== '.cursorrules');
await createBackup(['CLAUDE.md', '.cursorrules'], mockServices);
expect(mockServices.fs.writeFile).toHaveBeenCalledTimes(1);
});
});
- Step 2: Run test to verify it fails
Run: pnpm nx test cli -- --testPathPattern backup Expected: FAIL -- module not found
- Step 3: Write implementation
// packages/cli/src/utils/backup.ts
import { basename } from 'path';
import type { CliServices } from '../services.js';
export interface BackupResult {
dir: string;
files: string[];
}
/**
* Check if current directory is a git repository.
*/
export function isGitRepo(services: CliServices): boolean {
return services.fs.existsSync('.git');
}
/**
* Create a timestamped backup of the given files.
* Only backs up files that exist on disk.
*/
export async function createBackup(
filePaths: string[],
services: CliServices
): Promise<BackupResult> {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const dir = `.prs-backup/${timestamp}`;
await services.fs.mkdir(dir, { recursive: true });
const backedUp: string[] = [];
for (const filePath of filePaths) {
if (!services.fs.existsSync(filePath)) continue;
const content = services.fs.readFileSync(filePath, 'utf-8');
const dest = `${dir}/${basename(filePath)}`;
await services.fs.writeFile(dest, content, 'utf-8');
backedUp.push(filePath);
}
return { dir, files: backedUp };
}
- Step 4: Run test to verify it passes
Run: pnpm nx test cli -- --testPathPattern backup Expected: PASS (4 tests)
- Step 5: Commit
git add packages/cli/src/utils/backup.ts packages/cli/src/utils/__tests__/backup.spec.ts
git commit -m "feat(cli): add backup utility with git repo detection"
Task 3: Migration Prompt Generator¶
Files:
- Create:
packages/cli/src/utils/migration-prompt.ts -
Test:
packages/cli/src/utils/__tests__/migration-prompt.spec.ts -
Step 1: Write failing tests
// packages/cli/src/utils/__tests__/migration-prompt.spec.ts
import { describe, it, expect } from 'vitest';
import { generateMigrationPrompt, type MigrationPromptInput } from '../migration-prompt.js';
describe('generateMigrationPrompt', () => {
const candidates: MigrationPromptInput[] = [
{ path: 'CLAUDE.md', sizeHuman: '3.4 KB', toolName: 'Claude Code' },
{ path: '.cursorrules', sizeHuman: '1.8 KB', toolName: 'Cursor' },
];
it('includes all candidate files in the prompt', () => {
const prompt = generateMigrationPrompt(candidates);
expect(prompt).toContain('CLAUDE.md');
expect(prompt).toContain('.cursorrules');
});
it('includes file sizes and tool names', () => {
const prompt = generateMigrationPrompt(candidates);
expect(prompt).toContain('3.4 KB');
expect(prompt).toContain('Claude Code');
});
it('includes /promptscript skill reference', () => {
const prompt = generateMigrationPrompt(candidates);
expect(prompt).toContain('/promptscript');
});
it('includes modular .prs structure instructions', () => {
const prompt = generateMigrationPrompt(candidates);
expect(prompt).toContain('project.prs');
expect(prompt).toContain('context.prs');
expect(prompt).toContain('standards.prs');
expect(prompt).toContain('restrictions.prs');
});
it('includes validation steps', () => {
const prompt = generateMigrationPrompt(candidates);
expect(prompt).toContain('prs validate');
expect(prompt).toContain('prs compile');
});
it('handles single candidate', () => {
const prompt = generateMigrationPrompt([candidates[0]!]);
expect(prompt).toContain('CLAUDE.md');
expect(prompt).not.toContain('.cursorrules');
});
});
- Step 2: Run test to verify it fails
Run: pnpm nx test cli -- --testPathPattern migration-prompt Expected: FAIL -- module not found
- Step 3: Write implementation
// packages/cli/src/utils/migration-prompt.ts
export interface MigrationPromptInput {
path: string;
sizeHuman: string;
toolName: string;
}
/**
* Generate a kick-start prompt for AI-assisted migration.
* The prompt tells the AI what files to read and how to produce .prs output.
* It does NOT include file contents -- the AI reads them from disk.
*/
export function generateMigrationPrompt(candidates: MigrationPromptInput[]): string {
const fileList = candidates.map((c) => `- ${c.path} (${c.sizeHuman}, ${c.toolName})`).join('\n');
return `Migrate my existing AI instructions to PromptScript.
I've just initialized PromptScript in this project. The following
instruction files need to be migrated to .prs format:
${fileList}
Use the /promptscript skill for the PromptScript language reference.
Steps:
1. Read each file listed above
2. Analyze the content and map to PromptScript blocks
3. Generate a modular .prs structure in .promptscript/:
- project.prs (entry: @meta, @identity, @use directives)
- context.prs (@context)
- standards.prs (@standards)
- restrictions.prs (@restrictions)
- commands.prs (@shortcuts, @knowledge -- only if relevant content found)
4. Deduplicate overlapping content across files
5. Run: prs validate --strict
6. Run: prs compile --dry-run
`;
}
- Step 4: Run test to verify it passes
Run: pnpm nx test cli -- --testPathPattern migration-prompt Expected: PASS (6 tests)
- Step 5: Commit
git add packages/cli/src/utils/migration-prompt.ts packages/cli/src/utils/__tests__/migration-prompt.spec.ts
git commit -m "feat(cli): add migration prompt generator for AI-assisted migration"
Chunk 2: Multi-file Import (Phase 2)¶
Task 4: Section Merger¶
Files:
- Create:
packages/importer/src/merger.ts - Test:
packages/importer/src/__tests__/merger.spec.ts
Context: Works with ScoredSection from confidence.ts. Each section has heading, content, targetBlock, confidence, level. The merger groups sections from multiple files by targetBlock and applies merge rules per block type.
- Step 1: Write failing tests
// packages/importer/src/__tests__/merger.spec.ts
import { describe, it, expect } from 'vitest';
import { mergeSections, type SourcedSection } from '../merger.js';
import { classifyConfidence } from '../confidence.js';
function section(
targetBlock: string,
content: string,
source: string,
confidence = 0.85
): SourcedSection {
return {
heading: targetBlock,
content,
targetBlock,
confidence,
level: classifyConfidence(confidence),
source,
};
}
describe('mergeSections', () => {
it('groups sections by targetBlock', () => {
const sections = [
section('identity', 'You are a TS expert', 'CLAUDE.md'),
section('standards', 'Use strict mode', 'CLAUDE.md'),
section('identity', 'You are helpful', '.cursorrules'),
];
const result = mergeSections(sections);
expect(result.merged.has('identity')).toBe(true);
expect(result.merged.has('standards')).toBe(true);
});
it('picks longest identity by character count', () => {
const sections = [
section('identity', 'You are a TypeScript expert working on complex systems', 'CLAUDE.md'),
section('identity', 'You are helpful', '.cursorrules'),
];
const result = mergeSections(sections);
const identity = result.merged.get('identity')!;
expect(identity.content).toContain('TypeScript expert');
expect(identity.reviewComments).toHaveLength(1);
expect(identity.reviewComments[0]).toContain('.cursorrules');
});
it('unions restrictions from all sources', () => {
const sections = [
section('restrictions', '- "Never use any"', 'CLAUDE.md'),
section('restrictions', '- "Never commit secrets"', '.cursorrules'),
];
const result = mergeSections(sections);
const restrictions = result.merged.get('restrictions')!;
expect(restrictions.content).toContain('Never use any');
expect(restrictions.content).toContain('Never commit secrets');
});
it('deduplicates exact-match lines after whitespace normalization', () => {
const sections = [
section('restrictions', '- "Never use any"', 'CLAUDE.md'),
section('restrictions', '- "Never use any"', '.cursorrules'),
section('restrictions', '- "Never commit secrets"', '.cursorrules'),
];
const result = mergeSections(sections);
const restrictions = result.merged.get('restrictions')!;
const lines = restrictions.content.split('\n').filter((l) => l.trim().length > 0);
expect(lines).toHaveLength(2);
expect(result.deduplicatedCount).toBeGreaterThan(0);
});
it('concatenates knowledge with source attribution', () => {
const sections = [
section('knowledge', 'API docs here', 'CLAUDE.md'),
section('knowledge', 'CLI reference', '.cursorrules'),
];
const result = mergeSections(sections);
const knowledge = result.merged.get('knowledge')!;
expect(knowledge.content).toContain('# Source: CLAUDE.md');
expect(knowledge.content).toContain('# Source: .cursorrules');
});
it('merges standards by concatenating with dedup', () => {
const sections = [
section('standards', 'typescript: ["Strict mode"]', 'CLAUDE.md'),
section('standards', 'naming: ["kebab-case"]', '.cursorrules'),
];
const result = mergeSections(sections);
const standards = result.merged.get('standards')!;
expect(standards.content).toContain('Strict mode');
expect(standards.content).toContain('kebab-case');
});
it('reports overall confidence', () => {
const sections = [
section('identity', 'You are expert', 'CLAUDE.md', 0.9),
section('standards', 'Use strict', 'CLAUDE.md', 0.7),
];
const result = mergeSections(sections);
expect(result.overallConfidence).toBeCloseTo(0.8, 1);
});
});
- Step 2: Run test to verify it fails
Run: pnpm nx test importer -- --testPathPattern merger Expected: FAIL -- module not found
- Step 3: Write implementation
// packages/importer/src/merger.ts
import type { ScoredSection } from './confidence.js';
/**
* A ScoredSection with source file attribution.
*/
export interface SourcedSection extends ScoredSection {
source: string;
}
/**
* A merged block ready for emission.
*/
export interface MergedBlock {
targetBlock: string;
content: string;
sources: string[];
confidence: number;
reviewComments: string[];
}
export interface MergeResult {
merged: Map<string, MergedBlock>;
deduplicatedCount: number;
overallConfidence: number;
}
/**
* Merge sections from multiple files by targetBlock.
*
* Merge rules per block type (from spec):
* - identity: pick longest (char count after trim), others -> review comments
* - restrictions: full union, dedup exact-match lines
* - knowledge: concatenate with source attribution headers
* - standards, context, shortcuts: concatenate with dedup
*/
export function mergeSections(sections: SourcedSection[]): MergeResult {
const grouped = new Map<string, SourcedSection[]>();
for (const s of sections) {
const existing = grouped.get(s.targetBlock);
if (existing) {
existing.push(s);
} else {
grouped.set(s.targetBlock, [s]);
}
}
const merged = new Map<string, MergedBlock>();
let totalDeduped = 0;
for (const [block, blockSections] of grouped) {
if (block === 'identity') {
merged.set(block, mergeIdentity(blockSections));
} else if (block === 'knowledge') {
merged.set(block, mergeWithAttribution(blockSections));
} else {
const result = mergeUnion(blockSections);
totalDeduped += result.deduped;
merged.set(block, result.block);
}
}
const allConfidences = sections.map((s) => s.confidence);
const overallConfidence =
allConfidences.length > 0
? allConfidences.reduce((a, b) => a + b, 0) / allConfidences.length
: 0;
return { merged, deduplicatedCount: totalDeduped, overallConfidence };
}
function mergeIdentity(sections: SourcedSection[]): MergedBlock {
const sorted = [...sections].sort((a, b) => b.content.trim().length - a.content.trim().length);
const winner = sorted[0]!;
const others = sorted.slice(1);
const reviewComments = others.map(
(s) => `# REVIEW: alt from ${s.source}: "${s.content.trim().slice(0, 60)}..."`
);
return {
targetBlock: 'identity',
content: winner.content,
sources: sections.map((s) => s.source),
confidence: winner.confidence,
reviewComments,
};
}
function mergeWithAttribution(sections: SourcedSection[]): MergedBlock {
const parts = sections.map((s) => `# Source: ${s.source}\n${s.content}`);
const avgConfidence = sections.reduce((sum, s) => sum + s.confidence, 0) / sections.length;
return {
targetBlock: sections[0]!.targetBlock,
content: parts.join('\n\n'),
sources: sections.map((s) => s.source),
confidence: avgConfidence,
reviewComments: [],
};
}
function mergeUnion(sections: SourcedSection[]): { block: MergedBlock; deduped: number } {
const seenLines = new Set<string>();
const uniqueLines: string[] = [];
let deduped = 0;
for (const s of sections) {
const lines = s.content.split('\n');
for (const line of lines) {
const normalized = line.trim().replace(/\s+/g, ' ');
if (normalized.length === 0) continue;
if (seenLines.has(normalized)) {
deduped++;
continue;
}
seenLines.add(normalized);
uniqueLines.push(line);
}
}
const avgConfidence = sections.reduce((sum, s) => sum + s.confidence, 0) / sections.length;
return {
block: {
targetBlock: sections[0]!.targetBlock,
content: uniqueLines.join('\n'),
sources: sections.map((s) => s.source),
confidence: avgConfidence,
reviewComments: [],
},
deduped,
};
}
- Step 4: Run test to verify it passes
Run: pnpm nx test importer -- --testPathPattern merger Expected: PASS (7 tests)
- Step 5: Commit
git add packages/importer/src/merger.ts packages/importer/src/__tests__/merger.spec.ts
git commit -m "feat(importer): add section merger with per-block merge strategies"
Task 5: Multi-file Importer & Modular Emitter¶
Files:
- Create:
packages/importer/src/multi-importer.ts - Modify:
packages/importer/src/emitter.ts(addemitModularFiles()) - Modify:
packages/importer/src/index.ts(export new modules) - Test:
packages/importer/src/__tests__/multi-importer.spec.ts
Context: Calls importFile() per file, attaches source attribution, passes to mergeSections(), then emits modular .prs files. Uses existing test fixtures in packages/importer/src/__tests__/fixtures/.
- Step 1: Write failing tests
// packages/importer/src/__tests__/multi-importer.spec.ts
import { describe, it, expect } from 'vitest';
import { resolve } from 'path';
import { importMultipleFiles } from '../multi-importer.js';
const fixturesDir = resolve(__dirname, 'fixtures');
describe('importMultipleFiles', () => {
it('imports multiple files and returns modular output', async () => {
const result = await importMultipleFiles(
[resolve(fixturesDir, 'sample-claude.md'), resolve(fixturesDir, 'sample-copilot.md')],
{ projectName: 'test-project' }
);
expect(result.files.has('project.prs')).toBe(true);
expect(result.files.get('project.prs')).toContain('@meta {');
expect(result.files.get('project.prs')).toContain('@use ./');
expect(result.overallConfidence).toBeGreaterThan(0);
});
it('project.prs contains @identity block', async () => {
const result = await importMultipleFiles([resolve(fixturesDir, 'sample-claude.md')], {
projectName: 'my-proj',
});
expect(result.files.get('project.prs')).toContain('@identity');
});
it('only emits files with content', async () => {
const result = await importMultipleFiles([resolve(fixturesDir, 'sample-claude.md')], {
projectName: 'test',
});
for (const [, content] of result.files) {
expect(content.trim().length).toBeGreaterThan(0);
}
});
it('returns per-file confidence reports', async () => {
const result = await importMultipleFiles(
[resolve(fixturesDir, 'sample-claude.md'), resolve(fixturesDir, 'sample-copilot.md')],
{ projectName: 'test' }
);
expect(result.perFileReports).toHaveLength(2);
expect(result.perFileReports[0]!.file).toContain('sample-claude.md');
expect(result.perFileReports[0]!.sectionCount).toBeGreaterThan(0);
});
it('reports deduplication count', async () => {
const result = await importMultipleFiles(
[resolve(fixturesDir, 'sample-claude.md'), resolve(fixturesDir, 'sample-copilot.md')],
{ projectName: 'test' }
);
expect(typeof result.deduplicatedCount).toBe('number');
});
it('skips files that fail to import with warnings', async () => {
const result = await importMultipleFiles(
[resolve(fixturesDir, 'sample-claude.md'), '/nonexistent/file.md'],
{ projectName: 'test' }
);
expect(result.files.has('project.prs')).toBe(true);
expect(result.warnings.some((w) => w.includes('/nonexistent/file.md'))).toBe(true);
});
it('handles single file input', async () => {
const result = await importMultipleFiles([resolve(fixturesDir, 'sample-claude.md')], {
projectName: 'single',
});
expect(result.files.has('project.prs')).toBe(true);
});
});
- Step 2: Run test to verify it fails
Run: pnpm nx test importer -- --testPathPattern multi-importer Expected: FAIL -- module not found
- Step 3: Add
emitModularFiles()to emitter.ts
Add to packages/importer/src/emitter.ts (after existing code):
import type { MergedBlock } from './merger.js';
export interface ModularEmitOptions {
projectName: string;
syntaxVersion?: string;
}
/**
* Emit merged blocks as modular .prs files.
* Returns a Map<filename, content> where filenames are relative to .promptscript/.
*
* Layout:
* - project.prs: @meta + @identity + @use directives
* - context.prs: @context (if exists)
* - standards.prs: @standards (if exists)
* - restrictions.prs: @restrictions (if exists)
* - commands.prs: @shortcuts + @knowledge (if exist)
*/
export function emitModularFiles(
blocks: Map<string, MergedBlock>,
options: ModularEmitOptions
): Map<string, string> {
const files = new Map<string, string>();
const syntaxVersion = options.syntaxVersion ?? '1.0.0';
const useDirectives: string[] = [];
const fileBlockMapping: Record<string, string[]> = {
'context.prs': ['context'],
'standards.prs': ['standards'],
'restrictions.prs': ['restrictions'],
'commands.prs': ['shortcuts', 'knowledge'],
};
for (const [filename, blockNames] of Object.entries(fileBlockMapping)) {
const relevantBlocks = blockNames
.map((name) => blocks.get(name))
.filter((b): b is MergedBlock => b !== undefined);
if (relevantBlocks.length === 0) continue;
const lines: string[] = [];
for (const block of relevantBlocks) {
for (const comment of block.reviewComments) {
lines.push(comment);
}
if (block.confidence < 0.5) {
lines.push('# REVIEW: low confidence -- verify this mapping');
}
lines.push(`@${block.targetBlock} {`);
lines.push(' """');
for (const contentLine of block.content.split('\n')) {
lines.push(` ${contentLine}`);
}
lines.push(' """');
lines.push('}');
lines.push('');
}
const useName = filename.replace('.prs', '');
useDirectives.push(`@use ./${useName}`);
files.set(filename, lines.join('\n'));
}
// Emit project.prs
const projectLines: string[] = [];
projectLines.push('@meta {');
projectLines.push(` id: "${options.projectName}"`);
projectLines.push(` syntax: "${syntaxVersion}"`);
projectLines.push('}');
projectLines.push('');
for (const dir of useDirectives) {
projectLines.push(dir);
}
const identity = blocks.get('identity');
if (identity) {
projectLines.push('');
for (const comment of identity.reviewComments) {
projectLines.push(comment);
}
projectLines.push('@identity {');
projectLines.push(' """');
for (const line of identity.content.split('\n')) {
projectLines.push(` ${line}`);
}
projectLines.push(' """');
projectLines.push('}');
}
projectLines.push('');
files.set('project.prs', projectLines.join('\n'));
return files;
}
- Step 4: Write multi-importer implementation
// packages/importer/src/multi-importer.ts
import { basename } from 'path';
import { importFile } from './importer.js';
import { mergeSections, type SourcedSection } from './merger.js';
import { emitModularFiles, type ModularEmitOptions } from './emitter.js';
export interface MultiImportOptions extends ModularEmitOptions {}
export interface FileReport {
file: string;
sectionCount: number;
confidence: number;
}
export interface MultiImportResult {
/** Map of filename -> .prs content (relative to .promptscript/) */
files: Map<string, string>;
perFileReports: FileReport[];
deduplicatedCount: number;
overallConfidence: number;
warnings: string[];
}
/**
* Import multiple instruction files and produce modular .prs output.
* Files that fail to import are skipped with warnings.
*/
export async function importMultipleFiles(
filePaths: string[],
options: MultiImportOptions
): Promise<MultiImportResult> {
const allSections: SourcedSection[] = [];
const perFileReports: FileReport[] = [];
const warnings: string[] = [];
for (const filePath of filePaths) {
try {
const result = await importFile(filePath);
const source = basename(filePath);
for (const section of result.sections) {
allSections.push({ ...section, source });
}
perFileReports.push({
file: filePath,
sectionCount: result.sections.length,
confidence: result.totalConfidence,
});
warnings.push(...result.warnings);
} catch (error) {
warnings.push(
`Could not import ${filePath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
if (allSections.length === 0) {
return {
files: new Map([['project.prs', emitEmptyProject(options)]]),
perFileReports,
deduplicatedCount: 0,
overallConfidence: 0,
warnings,
};
}
const mergeResult = mergeSections(allSections);
const files = emitModularFiles(mergeResult.merged, options);
return {
files,
perFileReports,
deduplicatedCount: mergeResult.deduplicatedCount,
overallConfidence: mergeResult.overallConfidence,
warnings,
};
}
function emitEmptyProject(options: MultiImportOptions): string {
return `@meta {
id: "${options.projectName}"
syntax: "${options.syntaxVersion ?? '1.0.0'}"
}
@identity {
"""
[No content could be imported. Edit this file manually.]
"""
}
`;
}
- Step 5: Update
packages/importer/src/index.tswith new exports
Add after existing exports:
export { mergeSections } from './merger.js';
export type { SourcedSection, MergedBlock, MergeResult } from './merger.js';
export { importMultipleFiles } from './multi-importer.js';
export type { MultiImportOptions, MultiImportResult, FileReport } from './multi-importer.js';
export { emitModularFiles } from './emitter.js';
export type { ModularEmitOptions } from './emitter.js';
- Step 6: Run test to verify it passes
Run: pnpm nx test importer -- --testPathPattern multi-importer Expected: PASS (7 tests)
- Step 7: Run all importer tests to ensure no regressions
Run: pnpm nx test importer Expected: All existing tests pass
- Step 8: Commit
git add packages/importer/src/multi-importer.ts packages/importer/src/emitter.ts packages/importer/src/merger.ts packages/importer/src/index.ts packages/importer/src/__tests__/multi-importer.spec.ts
git commit -m "feat(importer): add multi-file import with modular .prs output"
Chunk 3: Enhanced Detection & Init Types (Phase 3a)¶
Task 6: Enrich MigrationCandidate in ai-tools-detector¶
Files:
- Modify:
packages/cli/src/utils/ai-tools-detector.ts - Modify:
packages/cli/src/types.ts - Test:
packages/cli/src/utils/__tests__/ai-tools-detector.spec.ts(new)
Context: Currently migrationCandidates is string[]. We change it to MigrationCandidate[] with path, format, sizeBytes, sizeHuman, toolName. This impacts consumers in init.ts.
- Step 1: Write failing tests for enriched candidates
// packages/cli/src/utils/__tests__/ai-tools-detector.spec.ts
import { describe, it, expect, vi } from 'vitest';
import {
detectAITools,
hasMigrationCandidates,
type AIToolsDetection,
} from '../ai-tools-detector.js';
import type { CliServices } from '../../services.js';
vi.mock('@promptscript/importer', () => ({
detectFormat: vi.fn().mockReturnValue('claude'),
}));
describe('detectAITools -- enriched migration candidates', () => {
it('returns MigrationCandidate objects with metadata', async () => {
const mockServices = {
fs: {
existsSync: vi.fn().mockImplementation((p: string) => p === 'CLAUDE.md' || p === '.git'),
readFile: vi.fn().mockResolvedValue('# My instructions\nYou are a helpful assistant'),
readdir: vi.fn().mockResolvedValue([]),
readFileSync: vi.fn().mockReturnValue('# My instructions\nYou are a helpful assistant'),
},
prompts: {} as CliServices['prompts'],
cwd: '/mock',
} as unknown as CliServices;
const result = await detectAITools(mockServices);
const candidate = result.migrationCandidates.find((c) => c.path === 'CLAUDE.md');
expect(candidate).toBeDefined();
expect(candidate!.path).toBe('CLAUDE.md');
expect(candidate!.toolName).toBe('Claude Code');
expect(typeof candidate!.sizeBytes).toBe('number');
expect(typeof candidate!.sizeHuman).toBe('string');
});
it('hasMigrationCandidates works with enriched type', () => {
const detection: AIToolsDetection = {
detected: ['claude'],
details: { claude: ['CLAUDE.md'] },
migrationCandidates: [
{
path: 'CLAUDE.md',
format: 'claude',
sizeBytes: 1024,
sizeHuman: '1.0 KB',
toolName: 'Claude Code',
},
],
};
expect(hasMigrationCandidates(detection)).toBe(true);
});
it('hasMigrationCandidates returns false when empty', () => {
const detection: AIToolsDetection = {
detected: [],
details: {},
migrationCandidates: [],
};
expect(hasMigrationCandidates(detection)).toBe(false);
});
});
- Step 2: Run test to verify it fails
Run: pnpm nx test cli -- --testPathPattern ai-tools-detector Expected: FAIL -- type mismatch (string[] vs MigrationCandidate[])
- Step 3: Update
ai-tools-detector.ts
Key changes:
-
Add import at top:
import { detectFormat, type DetectedFormat } from '@promptscript/importer'; -
Add
MigrationCandidateinterface and export it:
export interface MigrationCandidate {
path: string;
format: DetectedFormat;
sizeBytes: number;
sizeHuman: string;
toolName: string;
}
-
Change
AIToolsDetection.migrationCandidatestype fromstring[]toMigrationCandidate[] -
Expand
INSTRUCTION_FILESto include:
- In
detectAITools(), replace the string push with enriched object (use async readFile to match existing pattern):
const content = await services.fs.readFile(file, 'utf-8');
const sizeBytes = Buffer.byteLength(content, 'utf-8');
migrationCandidates.push({
path: file,
format: detectFormat(file),
sizeBytes,
sizeHuman: formatFileSize(sizeBytes),
toolName: toolNameForFile(file),
});
- Add helper functions:
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
const FILE_TOOL_NAMES: Record<string, string> = {
'CLAUDE.md': 'Claude Code',
'claude.md': 'Claude Code',
'.cursorrules': 'Cursor',
'.github/copilot-instructions.md': 'GitHub Copilot',
'AGENTS.md': 'Factory AI / Codex',
'OPENCODE.md': 'OpenCode',
'GEMINI.md': 'Gemini CLI',
'.windsurfrules': 'Windsurf',
'.clinerules': 'Cline',
'.goosehints': 'Goose',
'augment-guidelines.md': 'Augment',
'codex.md': 'Codex',
'AI_INSTRUCTIONS.md': 'Generic',
'AI.md': 'Generic',
};
function toolNameForFile(file: string): string {
return FILE_TOOL_NAMES[file] ?? 'Unknown';
}
- Update
formatMigrationHint()to use enriched type. Complete replacement:
export function formatMigrationHint(detection: AIToolsDetection): string[] {
if (detection.migrationCandidates.length === 0) {
return [];
}
const lines: string[] = [];
lines.push('');
lines.push('Existing instruction files detected:');
for (const c of detection.migrationCandidates) {
lines.push(` - ${c.path} (${c.sizeHuman}, ${c.toolName})`);
}
lines.push('');
lines.push(' These can be migrated to PromptScript for unified management.');
lines.push(' Run: prs init --migrate');
lines.push(' See: https://getpromptscript.dev/latest/guides/ai-migration-best-practices');
return lines;
}
- Step 4: Update
types.ts
Add to InitOptions (note: import is a reserved keyword, use autoImport):
/** Non-interactive static import of detected files (--auto-import) */
autoImport?: boolean;
/** Create backup before migration */
backup?: boolean;
/** Internal: force migrate flow (used by prs migrate) */
_forceMigrate?: boolean;
/** Internal: force LLM flow (used by prs migrate --llm) */
_forceLlm?: boolean;
/** Internal: specific files to migrate (used by prs migrate --files) */
_migrateFiles?: string[];
Add new interface:
- Step 5: Run tests
Run: pnpm nx test cli -- --testPathPattern ai-tools-detector Expected: PASS
- Step 6: Run all CLI tests to check for regressions
Run: pnpm nx test cli Expected: All existing tests pass (consumers of migrationCandidates use hasMigrationCandidates() which checks .length)
- Step 7: Commit
git add packages/cli/src/utils/ai-tools-detector.ts packages/cli/src/types.ts packages/cli/src/utils/__tests__/ai-tools-detector.spec.ts
git commit -m "feat(cli): enrich migration candidates with size, format, and tool name"
Chunk 4: Enhanced Init Command (Phase 3b)¶
Task 7: Gateway Prompt & Migration Flow in init.ts¶
Files:
- Modify:
packages/cli/src/commands/init.ts - Test:
packages/cli/src/__tests__/init-migrate.spec.ts(new) - Modify:
packages/cli/src/__tests__/init-command.spec.ts(update exit code test)
Context: This is the largest task. It modifies initCommand() to add:
- Exit code 2 for "already initialized"
- Gateway prompt when migration candidates detected
- Static migration flow (calls
importMultipleFiles) - AI-assisted flow (installs skill + generates prompt + clipboard)
- Fresh start skill installation
-
--auto-importflag support for non-interactive mode -
Step 1: Write failing tests for the gateway + migration flows
// packages/cli/src/__tests__/init-migrate.spec.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { initCommand } from '../commands/init.js';
import type { CliServices } from '../services.js';
// Same mock setup as init-command.spec.ts
const mockFindPrettierConfig = vi.fn().mockReturnValue(null);
vi.mock('../prettier/loader.js', () => ({
findPrettierConfig: () => mockFindPrettierConfig(),
}));
vi.mock('../utils/manifest-loader.js', async (importOriginal) => {
const original = (await importOriginal()) as Record<string, unknown>;
return { ...original, loadManifestFromUrl: vi.fn().mockRejectedValue(new Error('n/a')) };
});
vi.mock('../config/user-config.js', () => ({
loadUserConfig: vi.fn().mockResolvedValue({ version: '1' }),
}));
vi.mock('ora', () => ({
default: vi.fn().mockReturnValue({
start: vi.fn().mockReturnThis(),
succeed: vi.fn().mockReturnThis(),
fail: vi.fn().mockReturnThis(),
warn: vi.fn().mockReturnThis(),
text: '',
}),
}));
vi.mock('chalk', () => ({
default: {
green: (s: string) => s,
red: (s: string) => s,
yellow: (s: string) => s,
blue: (s: string) => s,
gray: (s: string) => s,
},
}));
const mockImportMultipleFiles = vi.fn();
vi.mock('@promptscript/importer', () => ({
importMultipleFiles: (...args: unknown[]) => mockImportMultipleFiles(...args),
detectFormat: vi.fn().mockReturnValue('generic'),
}));
const mockCopyToClipboard = vi.fn().mockReturnValue(true);
vi.mock('../utils/clipboard.js', () => ({
copyToClipboard: (...args: unknown[]) => mockCopyToClipboard(...args),
}));
const mockIsGitRepo = vi.fn().mockReturnValue(true);
const mockCreateBackup = vi.fn().mockResolvedValue({ dir: '.prs-backup/2026', files: [] });
vi.mock('../utils/backup.js', () => ({
isGitRepo: (...args: unknown[]) => mockIsGitRepo(...args),
createBackup: (...args: unknown[]) => mockCreateBackup(...args),
}));
vi.spyOn(process, 'cwd').mockReturnValue('/mock/project');
describe('init -- migration flow', () => {
let mockServices: CliServices;
let mockFs: Record<string, ReturnType<typeof vi.fn>>;
let mockPrompts: Record<string, ReturnType<typeof vi.fn>>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
process.exitCode = undefined;
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// Default: CLAUDE.md exists as migration candidate, promptscript.yaml does NOT
mockFs = {
existsSync: vi.fn().mockImplementation((p: string) => p === 'CLAUDE.md'),
writeFile: vi.fn().mockResolvedValue(undefined),
mkdir: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue('# Project\nYou are a helpful assistant'),
readdir: vi.fn().mockResolvedValue([]),
readFileSync: vi.fn().mockReturnValue('# Project\nYou are a helpful assistant'),
};
mockPrompts = {
input: vi.fn().mockResolvedValue('test-project'),
confirm: vi.fn().mockResolvedValue(false),
checkbox: vi.fn().mockResolvedValue(['github', 'claude']),
select: vi.fn(),
};
mockServices = {
fs: mockFs as unknown as CliServices['fs'],
prompts: mockPrompts as unknown as CliServices['prompts'],
cwd: '/mock/project',
};
});
afterEach(() => {
consoleSpy.mockRestore();
});
it('sets exit code 2 when already initialized', async () => {
mockFs.existsSync = vi.fn().mockImplementation((p: string) => p === 'promptscript.yaml');
await initCommand({}, mockServices);
expect(process.exitCode).toBe(2);
});
it('shows gateway prompt when migration candidates detected (interactive)', async () => {
// Gateway: fresh-start (no second select for strategy needed)
// Then: registry skip, targets checkbox
mockPrompts.select = vi
.fn()
.mockResolvedValueOnce('fresh-start') // gateway
.mockResolvedValueOnce('skip'); // registry
await initCommand({ interactive: true }, mockServices);
// First select call should be the gateway
expect(mockPrompts.select).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('How would you like to start'),
})
);
});
it('shows strategy prompt when user picks migrate', async () => {
mockPrompts.select = vi
.fn()
.mockResolvedValueOnce('migrate') // gateway: migrate
.mockResolvedValueOnce('llm') // strategy: AI-assisted
.mockResolvedValueOnce('skip'); // registry
await initCommand({ interactive: true }, mockServices);
// Second select should be the strategy prompt
expect(mockPrompts.select).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('How do you want to migrate'),
})
);
});
it('--auto-import flag triggers static import in non-interactive mode', async () => {
mockImportMultipleFiles.mockResolvedValue({
files: new Map([['project.prs', '@meta { id: "test" syntax: "1.0.0" }']]),
perFileReports: [{ file: 'CLAUDE.md', sectionCount: 3, confidence: 0.85 }],
deduplicatedCount: 0,
overallConfidence: 0.85,
warnings: [],
});
await initCommand({ yes: true, autoImport: true }, mockServices);
expect(mockImportMultipleFiles).toHaveBeenCalled();
expect(mockFs.writeFile).toHaveBeenCalledWith(
'.promptscript/project.prs',
expect.stringContaining('@meta'),
'utf-8'
);
});
it('-y without --auto-import skips migration and shows hint', async () => {
await initCommand({ yes: true }, mockServices);
expect(mockImportMultipleFiles).not.toHaveBeenCalled();
expect(mockFs.writeFile).toHaveBeenCalledWith(
'.promptscript/project.prs',
expect.any(String),
'utf-8'
);
});
it('LLM flow installs skill and generates prompt', async () => {
await initCommand({ yes: true, _forceMigrate: true, _forceLlm: true }, mockServices);
// Should copy to clipboard
expect(mockCopyToClipboard).toHaveBeenCalled();
// Should save migration prompt file
expect(mockFs.writeFile).toHaveBeenCalledWith(
'.promptscript/migration-prompt.md',
expect.stringContaining('/promptscript'),
'utf-8'
);
});
it('backup is created when --backup flag is set', async () => {
mockImportMultipleFiles.mockResolvedValue({
files: new Map([['project.prs', '@meta { id: "test" syntax: "1.0.0" }']]),
perFileReports: [],
deduplicatedCount: 0,
overallConfidence: 0.85,
warnings: [],
});
await initCommand({ yes: true, autoImport: true, backup: true }, mockServices);
expect(mockCreateBackup).toHaveBeenCalled();
});
});
- Step 2: Run test to verify it fails
Run: pnpm nx test cli -- --testPathPattern init-migrate Expected: FAIL -- gateway prompt not implemented
- Step 3: Implement changes to init.ts
This is the most complex change. Key modifications:
3a. Change "already initialized" from return to process.exitCode = 2; return;
3b. Add new imports at top of init.ts:
import { importMultipleFiles } from '@promptscript/importer';
import { copyToClipboard } from '../utils/clipboard.js';
import { isGitRepo, createBackup } from '../utils/backup.js';
import { generateMigrationPrompt } from '../utils/migration-prompt.js';
import type { MigrationCandidate } from '../utils/ai-tools-detector.js';
3c. After detectAITools(), add migration mode determination:
let migrationMode: 'static' | 'llm' | 'skip' | 'none' = 'none';
if (hasMigrationCandidates(aiToolsDetection)) {
if (options.yes && options.autoImport) {
migrationMode = 'static';
} else if (options.yes) {
migrationMode = 'skip';
} else if (options._forceMigrate) {
migrationMode = options._forceLlm ? 'llm' : 'static';
} else {
migrationMode = await showGatewayPrompt(aiToolsDetection, services);
}
}
3d. Add showGatewayPrompt() function:
async function showGatewayPrompt(
detection: Awaited<ReturnType<typeof detectAITools>>,
services: CliServices
): Promise<'static' | 'llm' | 'skip'> {
ConsoleOutput.newline();
console.log('Found existing instruction files:');
for (const c of detection.migrationCandidates) {
ConsoleOutput.muted(` ${c.path} (${c.sizeHuman}, ${c.toolName})`);
}
ConsoleOutput.newline();
const gateway = await services.prompts.select({
message: 'How would you like to start?',
choices: [
{ name: 'Migrate existing instructions to PromptScript', value: 'migrate' },
{ name: 'Fresh start (ignore existing files)', value: 'fresh-start' },
],
});
if (gateway === 'fresh-start') return 'skip';
const strategy = await services.prompts.select({
message: 'How do you want to migrate?',
choices: [
{ name: 'Static import (fast, deterministic)', value: 'static' },
{ name: 'AI-assisted migration (installs skill + generates prompt)', value: 'llm' },
],
});
return strategy as 'static' | 'llm';
}
3e. Add handleMigrationBackup() helper (shared by static and LLM flows):
async function handleMigrationBackup(
candidates: MigrationCandidate[],
options: InitOptions,
services: CliServices
): Promise<void> {
const gitRepo = isGitRepo(services);
if (!gitRepo) {
ConsoleOutput.warn('Not a git repository. Files are not version-controlled.');
}
const shouldBackup =
options.backup ??
(options.yes
? false
: await services.prompts.confirm({
message: 'Create backup to .prs-backup/?',
default: !gitRepo,
}));
if (shouldBackup) {
const backupResult = await createBackup(
candidates.map((c) => c.path),
services
);
ConsoleOutput.info(`Backup created: ${backupResult.dir}`);
}
}
3f. Add handleStaticMigration() — full implementation:
async function handleStaticMigration(
candidates: MigrationCandidate[],
config: ResolvedConfig,
options: InitOptions,
services: CliServices
): Promise<Map<string, string>> {
await handleMigrationBackup(candidates, options, services);
// File selection (interactive only)
let selectedPaths = candidates.map((c) => c.path);
if (!options.yes) {
selectedPaths = await services.prompts.checkbox({
message: 'Select files to import:',
choices: candidates.map((c) => ({
name: `${c.path} (${c.sizeHuman}, ${c.toolName})`,
value: c.path,
checked: true,
})),
});
}
const spinner = createSpinner('Importing instruction files...').start();
const result = await importMultipleFiles(
selectedPaths.map((f) => resolve(process.cwd(), f)),
{ projectName: config.projectId }
);
spinner.succeed(`Imported ${result.perFileReports.length} files`);
// Show confidence report
ConsoleOutput.newline();
console.log('Import Summary:');
for (const report of result.perFileReports) {
const pct = Math.round(report.confidence * 100);
ConsoleOutput.muted(` ${basename(report.file)} -> ${report.sectionCount} sections (${pct}%)`);
}
ConsoleOutput.muted(` Overall confidence: ${Math.round(result.overallConfidence * 100)}%`);
if (result.deduplicatedCount > 0) {
ConsoleOutput.muted(` Deduplicated: ${result.deduplicatedCount} lines`);
}
for (const w of result.warnings) {
ConsoleOutput.warn(w);
}
return result.files;
}
Add import { basename, resolve } from 'path'; (resolve is already imported, basename may need adding).
3g. Add handleLlmMigration() — full implementation:
async function handleLlmMigration(
candidates: MigrationCandidate[],
options: InitOptions,
services: CliServices
): Promise<void> {
await handleMigrationBackup(candidates, options, services);
const prompt = generateMigrationPrompt(
candidates.map((c) => ({
path: c.path,
sizeHuman: c.sizeHuman,
toolName: c.toolName,
}))
);
// Save prompt to file
await services.fs.writeFile('.promptscript/migration-prompt.md', prompt, 'utf-8');
// Copy to clipboard
const copied = copyToClipboard(prompt);
if (copied) {
ConsoleOutput.success('Migration prompt copied to clipboard!');
} else {
ConsoleOutput.newline();
console.log(prompt);
}
ConsoleOutput.info('Saved to .promptscript/migration-prompt.md');
}
3h. Extract existing skill install logic (lines 146-181 of current init.ts) into installSkillToTargets():
function installSkillToTargets(targets: AIToolTarget[], services: CliServices): string[] {
const skillName = 'promptscript';
const skillSource = resolve(BUNDLED_SKILLS_DIR, skillName, 'SKILL.md');
const installedPaths: string[] = [];
try {
const rawSkillContent = readFileSync(skillSource, 'utf-8');
let skillContent = rawSkillContent;
const hasMarker =
rawSkillContent.includes('<!-- PromptScript') ||
rawSkillContent.includes('# promptscript-generated:');
if (!hasMarker && rawSkillContent.startsWith('---')) {
const yamlMarker = `# promptscript-generated: ${new Date().toISOString()}`;
skillContent = `---\n${yamlMarker}${rawSkillContent.slice(3)}`;
}
for (const target of targets) {
const targetSkillDir = getTargetSkillDir(target, skillName);
if (targetSkillDir) {
// Use sync mkdir+write since this is a best-effort install
services.fs.mkdir(targetSkillDir.dir, { recursive: true }).then(() => {
services.fs.writeFile(targetSkillDir.path, skillContent, 'utf-8');
});
installedPaths.push(targetSkillDir.path);
}
}
} catch {
ConsoleOutput.warn(`Could not install PromptScript skill from ${skillSource}`);
}
return installedPaths;
}
3i. In the file creation section, branch on migrationMode:
- If
migrationMode === 'static': callhandleStaticMigration(), write returned files to.promptscript/instead of scaffoldproject.prs - If
migrationMode === 'llm': write scaffoldproject.prs, then callhandleLlmMigration() - If
migrationMode === 'skip'or'none': existing scaffold behavior
3j. Always call installSkillToTargets() after file creation (for all modes). The existing --migrate code block that installs skills should be replaced by this call. For LLM mode, skill installation happens here too (before compile, so AI can discover it).
- Step 4: Update existing test in init-command.spec.ts
Change the "should warn when already initialized" test:
it('should set exit code 2 when already initialized', async () => {
mockFs.existsSync.mockImplementation((path: string) => path === 'promptscript.yaml');
await initCommand({}, mockServices);
expect(process.exitCode).toBe(2);
});
- Step 5: Run tests
Run: pnpm nx test cli -- --testPathPattern "init-migrate|init-command" Expected: PASS
- Step 6: Run full CLI test suite
Run: pnpm nx test cli Expected: All tests pass
- Step 7: Commit
git add packages/cli/src/commands/init.ts packages/cli/src/__tests__/init-migrate.spec.ts packages/cli/src/__tests__/init-command.spec.ts
git commit -m "feat(cli): add gateway prompt and migration flow to prs init"
Chunk 5: Migrate Command & CLI Registration (Phase 4)¶
Task 8: prs migrate Command¶
Files:
- Create:
packages/cli/src/commands/migrate.ts - Modify:
packages/cli/src/cli.ts -
Test:
packages/cli/src/__tests__/migrate-command.spec.ts -
Step 1: Write failing tests
// packages/cli/src/__tests__/migrate-command.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { migrateCommand } from '../commands/migrate.js';
const mockInitCommand = vi.fn().mockResolvedValue(undefined);
vi.mock('../commands/init.js', () => ({
initCommand: (...args: unknown[]) => mockInitCommand(...args),
}));
describe('migrateCommand', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('delegates to initCommand with _forceMigrate flag', async () => {
await migrateCommand({});
expect(mockInitCommand).toHaveBeenCalledWith(
expect.objectContaining({ _forceMigrate: true }),
expect.anything()
);
});
it('--static maps to yes + autoImport flags', async () => {
await migrateCommand({ static: true });
expect(mockInitCommand).toHaveBeenCalledWith(
expect.objectContaining({ yes: true, autoImport: true, _forceMigrate: true }),
expect.anything()
);
});
it('--llm maps to _forceLlm flag', async () => {
await migrateCommand({ llm: true });
expect(mockInitCommand).toHaveBeenCalledWith(
expect.objectContaining({ _forceMigrate: true, _forceLlm: true }),
expect.anything()
);
});
it('--files are passed through to initCommand', async () => {
await migrateCommand({ files: ['CLAUDE.md', '.cursorrules'] });
expect(mockInitCommand).toHaveBeenCalledWith(
expect.objectContaining({
_forceMigrate: true,
_migrateFiles: ['CLAUDE.md', '.cursorrules'],
}),
expect.anything()
);
});
it('always sets force: true to allow running on initialized projects', async () => {
await migrateCommand({});
expect(mockInitCommand).toHaveBeenCalledWith(
expect.objectContaining({ force: true }),
expect.anything()
);
});
});
- Step 2: Run test to verify it fails
Run: pnpm nx test cli -- --testPathPattern migrate-command Expected: FAIL -- module not found
- Step 3: Write implementation
// packages/cli/src/commands/migrate.ts
import { initCommand } from './init.js';
import type { MigrateOptions } from '../types.js';
import { type CliServices, createDefaultServices } from '../services.js';
/**
* prs migrate -- alias/shortcut to init migration path.
* Delegates to initCommand with appropriate flags.
*
* When promptscript.yaml already exists, force: true allows
* initCommand to re-enter and run migration-only flow
* (skipping re-init prompts since config exists).
*/
export async function migrateCommand(
options: MigrateOptions,
services: CliServices = createDefaultServices()
): Promise<void> {
const initOptions = {
_forceMigrate: true,
_forceLlm: options.llm ?? false,
_migrateFiles: options.files,
yes: options.static ?? false,
autoImport: options.static ?? false,
force: true, // Allow running on already-initialized projects
};
await initCommand(initOptions, services);
}
- Step 4: Update
cli.ts
Add --auto-import and --backup to init command registration:
.option('--auto-import', 'Automatically import existing instruction files (static)')
.option('--backup', 'Create .prs-backup/ before migration')
In the action handler, map Commander's camelCase to our option:
Add deprecation warning for --migrate in init action handler. Import ConsoleOutput at top of cli.ts:
Then in the init action:
if (opts.migrate) {
ConsoleOutput.warn('--migrate is deprecated. The migration flow is now built into prs init.');
}
Register new migrate command:
program
.command('migrate')
.description('Migrate existing AI instructions to PromptScript')
.option('--static', 'Non-interactive static import of all detected files')
.option('--llm', 'Generate AI-assisted migration prompt')
.option('--files <files...>', 'Specific files to import')
.action(async (opts) => {
const { migrateCommand } = await import('./commands/migrate.js');
await migrateCommand(opts);
});
- Step 5: Run test to verify it passes
Run: pnpm nx test cli -- --testPathPattern migrate-command Expected: PASS (3 tests)
- Step 6: Run full CLI tests
Run: pnpm nx test cli Expected: All pass
- Step 7: Commit
git add packages/cli/src/commands/migrate.ts packages/cli/src/cli.ts packages/cli/src/__tests__/migrate-command.spec.ts
git commit -m "feat(cli): add prs migrate command as alias to init migration flow"
Chunk 6: Documentation & Final Verification (Phase 5)¶
Task 9: Update SKILL.md¶
Files:
-
Modify:
packages/cli/skills/promptscript/SKILL.md -
Step 1: Update CLI Commands section
In the ## CLI Commands section, add prs migrate entries after existing commands:
prs migrate # Interactive migration flow
prs migrate --static # Non-interactive static import
prs migrate --llm # Generate AI-assisted migration prompt
- Step 2: Commit
git add packages/cli/skills/promptscript/SKILL.md
git commit -m "docs(cli): add prs migrate to SKILL.md CLI commands"
Task 10: Full Verification Pipeline¶
- Step 1: Format
Run: pnpm run format
- Step 2: Lint
Run: pnpm run lint
- Step 3: Typecheck
Run: pnpm run typecheck
- Step 4: Test all
Run: pnpm run test
- Step 5: Validate .prs files
Run: pnpm prs validate --strict
- Step 6: Schema check
Run: pnpm schema:check
- Step 7: Skill check
Run: pnpm skill:check
- Step 8: Fix any issues found in steps 1-7
Iterate until all checks pass.
- Step 9: Final commit if any fixes were needed