diff --git a/.claude/skills/promptscript/SKILL.md b/.claude/skills/promptscript/SKILL.md index 632faa1897..f2d7748615 100644 --- a/.claude/skills/promptscript/SKILL.md +++ b/.claude/skills/promptscript/SKILL.md @@ -522,8 +522,11 @@ prs upgrade # Upgrade all .prs files to the latest version ## CLI Commands ``` -prs init # Initialize project -prs init --migrate # Initialize + migration skills +prs init # Initialize project (auto-detects existing files) +prs init --auto-import # Initialize + static import of existing files +prs migrate # Interactive migration flow +prs migrate --static # Non-interactive static import +prs migrate --llm # Generate AI-assisted migration prompt prs compile # Compile to all targets prs compile --watch # Watch mode prs validate --strict # Validate syntax diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d96ea7a54..9cf16870ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,7 +184,7 @@ jobs: node $GITHUB_WORKSPACE/cli-package/bin/prs.js validate node $GITHUB_WORKSPACE/cli-package/bin/prs.js compile - - name: Smoke test - init without --migrate (verify no migration files) + - name: Smoke test - init without --migrate (verify skill always installed) run: | mkdir -p /tmp/test-no-migrate cd /tmp/test-no-migrate @@ -196,8 +196,8 @@ jobs: test -f promptscript.yaml test -f .promptscript/project.prs - # Verify migration skill does NOT exist when --migrate is not used - test ! -f .promptscript/skills/promptscript/SKILL.md + # Verify PromptScript skill is always installed (fresh start installs skill) + test -f .promptscript/skills/promptscript/SKILL.md - name: Smoke test - all targets compilation run: | diff --git a/.promptscript/skills/promptscript/SKILL.md b/.promptscript/skills/promptscript/SKILL.md index 632faa1897..f2d7748615 100644 --- a/.promptscript/skills/promptscript/SKILL.md +++ b/.promptscript/skills/promptscript/SKILL.md @@ -522,8 +522,11 @@ prs upgrade # Upgrade all .prs files to the latest version ## CLI Commands ``` -prs init # Initialize project -prs init --migrate # Initialize + migration skills +prs init # Initialize project (auto-detects existing files) +prs init --auto-import # Initialize + static import of existing files +prs migrate # Interactive migration flow +prs migrate --static # Non-interactive static import +prs migrate --llm # Generate AI-assisted migration prompt prs compile # Compile to all targets prs compile --watch # Watch mode prs validate --strict # Validate syntax diff --git a/docs/design/2026-03-20-intelligent-init-design.md b/docs/design/2026-03-20-intelligent-init-design.md new file mode 100644 index 0000000000..53ee3585fb --- /dev/null +++ b/docs/design/2026-03-20-intelligent-init-design.md @@ -0,0 +1,437 @@ +# Intelligent `prs init` & Migration Flow + +**Date:** 2026-03-20 +**Status:** Approved +**Scope:** `packages/cli`, `packages/importer` + +## Problem + +The current `prs init` creates config + template files but only shows a passive hint when existing AI instruction files are detected. Migration requires separate manual steps (`prs import`, `prs init --migrate`) that are disconnected from the init flow. Users must figure out the migration path themselves. + +## Goals + +1. Detect existing instruction files and **offer migration inline** during init +2. Support **static import** (fast, deterministic) and **AI-assisted migration** (skill + kick-start prompt) +3. Multi-file import with **modular .prs output** (recommended project structure) +4. Minimize manual work — guide the user step by step +5. Safe for CI/CD with explicit opt-in flags + +## Non-Goals + +- Reverse sync (importing manual changes from compiled output back to .prs) — future feature +- LLM API calls from CLI — we generate prompts, not call models +- Auto-compile after init — user reviews .prs first + +--- + +## Design + +### 1. Command Flow & Decision Tree + +``` +prs init + │ + ├─ promptscript.yaml exists? (no --force) + │ YES → "Already initialized" + │ → hint: "Use --force to reinitialize" + │ → exit(2) + │ + ├─ Detect: project info, AI tools, migration candidates + │ + ├─ Migration candidates found? + │ │ + │ ├─ YES → Gateway prompt: + │ │ "Found existing instruction files:" + │ │ " CLAUDE.md (3.4 KB, Claude Code)" + │ │ " .cursorrules (1.8 KB, Cursor)" + │ │ + │ │ ? How would you like to start? + │ │ > 🔄 Migrate existing instructions to PromptScript + │ │ ✨ Fresh start (ignore existing files) + │ │ + │ └─ NO → straight to Fresh Start flow + │ + ├─ FRESH START flow: + │ 1. Standard init prompts (name, registry, targets) + │ 2. Create promptscript.yaml + scaffold .promptscript/project.prs + │ 3. Install PromptScript skill to all targets + │ 4. "Next: use /promptscript in your AI tool to fill in project.prs" + │ + └─ MIGRATE flow: + 1. Standard init prompts (name, registry, targets) + 2. Create promptscript.yaml + 3. Migration strategy prompt: + ? How do you want to migrate? + > 📋 Static import (fast, deterministic) + 🤖 AI-assisted migration (installs skill + generates prompt) + │ + ├─ STATIC: + │ 1. Git safety check (warn if no git repo) + │ 2. Ask: "Backup to .prs-backup/?" (default: no if git, yes if no git) + │ 3. Select files to import (checkbox, all pre-checked) + │ 4. Run multi-file import → modular .prs structure + │ 5. Show confidence report + │ 6. Install PromptScript skill to targets + │ 7. "Next: review .prs files, then prs compile" + │ + └─ AI-ASSISTED: + 1. Git safety check (warn if no git repo) + 2. Ask: "Backup to .prs-backup/?" (default: no if git, yes if no git) + 3. Create scaffold .promptscript/project.prs + 4. Install PromptScript skill to targets (before compile) + 5. Generate kick-start prompt (file list + migration instructions) + 6. Copy to clipboard / display in terminal + 7. Save to .promptscript/migration-prompt.md + 8. "Next: paste this prompt in your AI tool" +``` + +### 2. Multi-file Static Import & Modular Output + +#### Import pipeline + +``` +Detected files: CLAUDE.md, .cursorrules, copilot-instructions.md + │ │ │ + ▼ ▼ ▼ + importFile() importFile() importFile() + │ │ │ + ▼ ▼ ▼ + ScoredSection[] per file (with source attribution) + │ + ▼ + mergeSections() — group by target block type + │ + ▼ + Deduplicate — exact match after whitespace normalization + │ + ▼ + Emit modular .prs structure +``` + +#### Output structure + +``` +.promptscript/ + project.prs # Entry: @meta, @inherit, @use directives, @identity + context.prs # @context (merged from all sources) + standards.prs # @standards (merged, categorized) + restrictions.prs # @restrictions (union of all sources) + commands.prs # @shortcuts + @knowledge (if found, grouped as supplementary content) +``` + +Files are only emitted if they contain content. If no `@shortcuts` or `@knowledge` sections are found, `commands.prs` is not created. Users can reorganize the modular structure after import. + +`project.prs` contains `@use` directives pointing to the modular files: + +```promptscript +@meta { + id: "my-project" + syntax: "1.4.7" +} + +@use ./context +@use ./standards +@use ./restrictions +@use ./commands + +@identity { + """ + [merged identity from primary source] + """ +} +``` + +#### Merge rules per block type + +| Block | Strategy | Conflict resolution | +| --------------- | ------------------------------------------------- | ------------------------------------------------ | +| `@identity` | Pick longest (highest character count after trim) | Others added as `# REVIEW: alt from ` | +| `@context` | Union structured fields | Deduplicate identical entries | +| `@standards` | Merge by category key | Concatenate arrays, source comment on conflicts | +| `@restrictions` | Full union | Restrictions are additive — no conflicts | +| `@shortcuts` | Merge all | Same command name → keep longer, comment shorter | +| `@knowledge` | Concatenate | Source attribution headers between sections | + +#### Confidence reporting + +Sections below 50% confidence get `# REVIEW: low confidence — verify this mapping` in output. + +``` +Import Summary: + CLAUDE.md → 5 sections (87% confidence) + .cursorrules → 3 sections (82% confidence) + copilot-instr.md → 4 sections (85% confidence) + + Merged: 8 unique sections (3 deduplicated) + Review needed: 2 sections marked # REVIEW + Overall confidence: 84% +``` + +### 3. AI-Assisted Migration (Skill + Kick-start Prompt) + +The PromptScript CLI ships a built-in skill (`packages/cli/skills/promptscript/SKILL.md`) that is normally auto-injected during `prs compile`. For migration, we install it **before** compile to avoid overwriting existing instruction files. + +#### Kick-start prompt template + +The generated prompt does NOT include full file contents (they're on disk — AI can read them). It includes: + +```markdown +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: + +- CLAUDE.md (3.4 KB, Claude Code) +- .cursorrules (1.8 KB, Cursor) +- .github/copilot-instructions.md (2.1 KB, GitHub Copilot) + +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) +4. Deduplicate overlapping content across files +5. Run: prs validate --strict +6. Run: prs compile --dry-run +``` + +#### Clipboard delivery + +- macOS: `pbcopy` +- Linux: `xclip -selection clipboard` or `xsel --clipboard` +- Windows: `clip` +- Fallback: print to terminal + "Could not copy to clipboard" +- Always save to `.promptscript/migration-prompt.md` + +#### Tool-specific next steps + +After prompt generation, show invocation hints per detected target: + +``` +Paste this prompt in your AI tool: + Claude Code: just paste in chat + Cursor: Cmd+I → paste + GitHub Copilot: open Copilot Chat → paste +``` + +### 4. CLI Flags & Non-interactive Mode + +#### `prs init` flags + +``` +prs init # Interactive — gateway choice if files detected +prs init -y # Non-interactive, safe defaults, skip migration +prs init -y --import # Non-interactive + static import of detected files +prs init -i # Force interactive even with all args provided +prs init -f # Force reinitialize +prs init --backup # Create .prs-backup/ +prs init -m, --migrate # DEPRECATED → deprecation notice, installs skill +``` + +The `--migrate` flag stays for backward compatibility with a deprecation warning: + +``` +⚠ --migrate is deprecated. Use interactive mode: prs init + The migration flow is now built into the standard init process. +``` + +#### `prs migrate` command + +Alias/shortcut to the init migration path: + +``` +prs migrate # Alias → prs init (enters migrate flow) +prs migrate --static # Non-interactive: imports ALL detected files without prompting +prs migrate --llm # Non-interactive: generates kick-start prompt to stdout +prs migrate --files # Selective: import only specified files +``` + +`--static` imports all detected candidates without confirmation (equivalent to `prs init -y --import`). Use `--files` for selective non-interactive import. `--llm` pre-selects the AI-assisted path and emits the prompt to stdout (no Inquirer prompts). + +Behavior depends on whether `promptscript.yaml` exists: + +- **No `promptscript.yaml`:** Runs `initCommand()` with the migrate flow pre-selected (skips gateway prompt). +- **`promptscript.yaml` exists:** Runs migration flow only (skips init prompts — config already exists). Detects migration candidates and offers static/AI-assisted import into the existing `.promptscript/` directory. This allows users to add migration to an already-initialized project without `--force`. + +Reverse sync (re-importing manual edits from compiled output) is out of scope — future feature. + +#### Non-interactive behavior matrix + +| Flags | Files detected? | Behavior | +| ---------------------- | --------------- | ------------------------------------------------------------------------ | +| `-y` | No | Standard init, scaffold project.prs, install skill | +| `-y` | Yes | Standard init, scaffold project.prs, install skill, hint about migration | +| `-y --import` | No | Standard init (nothing to import) | +| `-y --import` | Yes | Standard init + static multi-file import → modular .prs | +| `-y --import --backup` | Yes | Same + backup to `.prs-backup/` | +| (no flags) | No | Interactive fresh start | +| (no flags) | Yes | Interactive gateway: migrate vs fresh start | + +#### Exit codes + +| Code | Meaning | +| ---- | --------------------------------------- | +| 0 | Success | +| 1 | General failure | +| 2 | Already initialized (without `--force`) | + +#### Git safety check + +Before migration (static or AI-assisted): + +- If not a git repo → warn, default backup to "yes" +- If git repo → default backup to "no" + +**Backup directory:** Uses timestamped subdirectory: `.prs-backup//`. Multiple backups can coexist without collision. The `@use` directive convention is without file extension (e.g., `@use ./context` resolves to `./context.prs`). + +#### Stdout vs stderr + +- Human-readable output → stderr (spinners, summary, hints) +- Kick-start prompt → stdout **only in non-interactive mode** (pipe-friendly: `prs migrate --llm 2>/dev/null | pbcopy`). In interactive mode, the prompt is copied to clipboard and/or saved to file — not emitted to stdout (which would conflict with Inquirer prompts). +- Structured output → stdout with `--format json` +- All Inquirer prompts use stderr as their output stream (Inquirer supports this via the `output` option). + +### 5. Fresh Start: Skill Installation + +For the "Fresh start" path (no migration), after creating config and scaffold files, `prs init` installs the built-in PromptScript skill directly to all target directories. This allows the AI tool to immediately help the user edit `.prs` files using `/promptscript` — without needing `prs compile` first (which would generate output from a placeholder template). + +Once the user finishes editing `.prs` files, they run `prs compile` which generates output AND auto-injects the skill (normal compile behavior). + +### 6. Enhanced Migration Candidate Detection + +The `AIToolsDetection.migrationCandidates` type changes from `string[]` to enriched objects: + +```typescript +interface MigrationCandidate { + path: string; + format: DetectedFormat; // 'claude' | 'github' | 'cursor' | 'generic' + sizeBytes: number; + sizeHuman: string; // "3.4 KB" + toolName: string; // "Claude Code", "Cursor", etc. +} +``` + +**Format detection mapping:** The importer's `DetectedFormat` is currently `'claude' | 'github' | 'cursor' | 'generic'`. Files without a dedicated parser (AGENTS.md, OPENCODE.md, GEMINI.md, .windsurfrules, .clinerules, AI_INSTRUCTIONS.md, AI.md) fall through to `'generic'`. The generic parser handles markdown-based instruction files adequately for static import. Expanding `DetectedFormat` with additional parsers is future work and not required for this design. + +**Ambiguous file ownership:** Some files (e.g., `AGENTS.md`) appear in detection patterns for multiple tools (Factory, Codex, Amp). The `toolName` field uses the first matching tool pattern from `AI_TOOL_PATTERNS` (ordered by specificity). The `format` field is independent — determined by the importer's `detectFormat()`, not by the AI tool detection. + +This is an internal CLI interface change — no public API impact. + +--- + +## Implementation Phases + +### Phase 1: Foundation utilities (no breaking changes) + +**New files:** + +- `packages/cli/src/utils/clipboard.ts` — cross-platform clipboard +- `packages/cli/src/utils/backup.ts` — `.prs-backup/` creation + git repo detection +- `packages/cli/src/utils/migration-prompt.ts` — kick-start prompt generator + +**Tests:** + +- `packages/cli/src/__tests__/clipboard.spec.ts` +- `packages/cli/src/__tests__/backup.spec.ts` +- `packages/cli/src/__tests__/migration-prompt.spec.ts` + +### Phase 2: Multi-file import in `@promptscript/importer` + +**New files:** + +- `packages/importer/src/merger.ts` — section dedup + merge by block type +- `packages/importer/src/multi-importer.ts` — batch import → modular .prs output + +**Modified files:** + +- `packages/importer/src/emitter.ts` — source attribution comments, modular emit +- `packages/importer/src/index.ts` — export new functions + +**Tests:** + +- `packages/importer/src/__tests__/merger.spec.ts` +- `packages/importer/src/__tests__/multi-importer.spec.ts` + +### Phase 3: Enhanced init command + +**Modified files:** + +- `packages/cli/src/commands/init.ts` — gateway choice, migrate flow, skill install for fresh start, kick-start prompt generation +- `packages/cli/src/types.ts` — add `import` flag to `InitOptions` +- `packages/cli/src/utils/ai-tools-detector.ts` — enrich `MigrationCandidate` with size, format, toolName + +**Tests:** + +- `packages/cli/src/__tests__/init-migrate.spec.ts` (new) +- `packages/cli/src/__tests__/init-command.spec.ts` (update) + +### Phase 4: `prs migrate` command + +**New files:** + +- `packages/cli/src/commands/migrate.ts` — thin wrapper delegating to init + +**Modified files:** + +- `packages/cli/src/cli.ts` — register migrate command + `--import` flag on init, deprecation for `--migrate` + +**Tests:** + +- `packages/cli/src/__tests__/migrate-command.spec.ts` + +### Phase 5: Documentation + +**Modified files:** + +- `packages/cli/skills/promptscript/SKILL.md` — update CLI commands section +- New migration guide (location TBD based on docs structure) + +### Dependency graph + +``` +Phase 1 (utils) ─────────────┐ + ├─→ Phase 3 (init) ─→ Phase 4 (migrate) ─→ Phase 5 (docs) +Phase 2 (multi-import) ──────┘ +``` + +Phases 1 and 2 are independent and can be parallelized. + +### Scope summary + +| Phase | New source files | Modified source files | New test files | +| --------- | --------------------- | --------------------- | ---------------------- | +| 1 | 3 | 0 | 3 | +| 2 | 2 | 2 | 2 | +| 3 | 1 (init-migrate test) | 3 | update 1 existing test | +| 4 | 2 (command + test) | 1 | — | +| 5 | 0 | 2 | 0 | +| **Total** | **8** | **8** | **5 new + 1 update** | + +--- + +## Error Handling + +| Scenario | Handling | +| -------------------------------------------- | ------------------------------------------------------- | +| File detected but unreadable | Skip with warning, continue with remaining files | +| Import produces 0 sections | Skip with warning: "Could not parse X" | +| All sections LOW confidence (<50%) | Warn: "Low confidence — consider AI-assisted migration" | +| `promptscript.yaml` exists without `--force` | Exit with code 2 | +| User cancels (Ctrl+C) | Catch `ExitPromptError`, clean exit | +| Clipboard unavailable | Fallback to terminal display | +| Not a git repo + no backup | Warn and default backup prompt to "yes" | + +## Backward Compatibility + +- `prs init -y` behavior unchanged (scaffold, no auto-import) +- `prs init --migrate` continues to work (installs skill) with deprecation notice +- `prs import ` single-file import unchanged +- New behavior only activates in interactive mode when candidates detected, or with explicit `--import` flag +- **Exit code change:** "Already initialized" previously exited with code 0 (warn + return). Now exits with code 2. This is a breaking change for scripts that check exit codes — document in release notes. diff --git a/docs/plans/2026-03-21-intelligent-init.md b/docs/plans/2026-03-21-intelligent-init.md new file mode 100644 index 0000000000..646cde44f1 --- /dev/null +++ b/docs/plans/2026-03-21-intelligent-init.md @@ -0,0 +1,2000 @@ +# 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//` 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** + +```typescript +// 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** + +```typescript +// 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 = { + 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** + +```bash +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** + +```typescript +// 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** + +```typescript +// 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 { + 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** + +```bash +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** + +```typescript +// 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** + +```typescript +// 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** + +```bash +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** + +```typescript +// 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** + +```typescript +// 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; + 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(); + 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(); + 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(); + 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** + +```bash +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** + +```typescript +// 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): + +```typescript +import type { MergedBlock } from './merger.js'; + +export interface ModularEmitOptions { + projectName: string; + syntaxVersion?: string; +} + +/** + * Emit merged blocks as modular .prs files. + * Returns a Map 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, + options: ModularEmitOptions +): Map { + const files = new Map(); + const syntaxVersion = options.syntaxVersion ?? '1.0.0'; + + const useDirectives: string[] = []; + const fileBlockMapping: Record = { + '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** + +```typescript +// 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; + 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 { + 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: + +```typescript +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** + +```bash +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** + +```typescript +// 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: + +```typescript +export interface MigrationCandidate { + path: string; + format: DetectedFormat; + sizeBytes: number; + sizeHuman: string; + toolName: string; +} +``` + +3. Change `AIToolsDetection.migrationCandidates` type from `string[]` to `MigrationCandidate[]` + +4. Expand `INSTRUCTION_FILES` to include: + +```typescript +'.windsurfrules', '.clinerules', '.goosehints', +'augment-guidelines.md', 'codex.md', +``` + +5. In `detectAITools()`, replace the string push with enriched object (use async readFile to match existing pattern): + +```typescript +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), +}); +``` + +6. Add helper functions: + +```typescript +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + return `${(bytes / 1024).toFixed(1)} KB`; +} + +const FILE_TOOL_NAMES: Record = { + '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'; +} +``` + +7. Update `formatMigrationHint()` to use enriched type. Complete replacement: + +```typescript +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`): + +```typescript +/** 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: + +```typescript +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** + +```bash +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 + +- [ ] **Step 1: Write failing tests for the gateway + migration flows** + +```typescript +// 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; + 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>; + let mockPrompts: Record>; + let consoleSpy: ReturnType; + + 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: + +```typescript +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: + +```typescript +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: + +```typescript +async function showGatewayPrompt( + detection: Awaited>, + 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): + +```typescript +async function handleMigrationBackup( + candidates: MigrationCandidate[], + options: InitOptions, + services: CliServices +): Promise { + 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: + +```typescript +async function handleStaticMigration( + candidates: MigrationCandidate[], + config: ResolvedConfig, + options: InitOptions, + services: CliServices +): Promise> { + 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: + +```typescript +async function handleLlmMigration( + candidates: MigrationCandidate[], + options: InitOptions, + services: CliServices +): Promise { + 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()`: + +```typescript +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('