Skip to content

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 (add emitModularFiles())
  • 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.ts with 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:

  1. Add import at top: import { detectFormat, type DetectedFormat } from '@promptscript/importer';

  2. Add MigrationCandidate interface and export it:

export interface MigrationCandidate {
  path: string;
  format: DetectedFormat;
  sizeBytes: number;
  sizeHuman: string;
  toolName: string;
}
  1. Change AIToolsDetection.migrationCandidates type from string[] to MigrationCandidate[]

  2. Expand INSTRUCTION_FILES to include:

'.windsurfrules', '.clinerules', '.goosehints',
'augment-guidelines.md', 'codex.md',
  1. 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),
});
  1. 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';
}
  1. 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:

export interface MigrateOptions {
  static?: boolean;
  llm?: boolean;
  files?: string[];
}
  • 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:

  1. Exit code 2 for "already initialized"
  2. Gateway prompt when migration candidates detected
  3. Static migration flow (calls importMultipleFiles)
  4. AI-assisted flow (installs skill + generates prompt + clipboard)
  5. Fresh start skill installation
  6. --auto-import flag support for non-interactive mode

  7. 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': call handleStaticMigration(), write returned files to .promptscript/ instead of scaffold project.prs
  • If migrationMode === 'llm': write scaffold project.prs, then call handleLlmMigration()
  • 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:

.action((opts) => initCommand({ ...opts, autoImport: opts.autoImport }, services));

Add deprecation warning for --migrate in init action handler. Import ConsoleOutput at top of cli.ts:

import { ConsoleOutput } from './output/console.js';

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
git add -A
git commit -m "chore: fix lint/type/test issues from verification pipeline"