diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..83febff --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- **`ai-rules migrate`** – One-way migration from the ai-rules–managed layout to the [agents.md](https://agents.md/) standard: writes root `AGENTS.md`, moves `ai-rules/skills` and `ai-rules/commands` into `.agents/`, removes generated files, and purges the `ai-rules/` directory. Supports `--nested-depth`, `--dry-run`, and `--force`; prompts for confirmation unless `--force` or `--dry-run`. See [Migration guide](docs/migration.md). + +## [1.5.0] - (see GitHub releases) diff --git a/README.md b/README.md index 9727bfa..cda63b0 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ curl -fsSL https://raw.githubusercontent.com/block/ai-rules/main/scripts/install | `ai-rules generate` | Generate rules for AI coding agents | | `ai-rules status` | Show sync status of AI rules | | `ai-rules clean` | Remove all generated files | +| `ai-rules migrate` | Migrate from ai-rules/ layout to agents.md standard (one-way) | | `ai-rules list-agents` | List all supported agents | ### Common Options @@ -89,6 +90,10 @@ AMP, Claude Code, Cline, Codex, Copilot, Cursor, Firebender, Gemini, Goose, Kilo See [Supported Agents](docs/agents.md) for detailed compatibility information. +## Migration to agents.md standard + +You can migrate from the ai-rules–managed layout to the [agents.md](https://agents.md/) standard: a single `AGENTS.md` at project root, skills in `.agents/skills/`, and commands in `.agents/commands/`. Run `ai-rules migrate` to write root `AGENTS.md`, move `ai-rules/skills` and `ai-rules/commands` into `.agents/`, remove generated files, and purge the `ai-rules/` directory. **This is one-way**; after migrating, the project no longer uses ai-rules generate/clean/status for that content. Use `--dry-run` first to see what would be done, then run without it (and confirm) or with `--force` to migrate. See [Migration guide](docs/migration.md) for details. + ## Documentation - [Configuration](docs/configuration.md) - Config file options and precedence @@ -97,6 +102,7 @@ See [Supported Agents](docs/agents.md) for detailed compatibility information. - [MCP Configuration](docs/mcp.md) - Model Context Protocol setup - [Commands and Skills](docs/commands-and-skills.md) - Custom commands and skills - [Project Structure](docs/project-structure.md) - Example project layouts +- [Migration to agents.md](docs/migration.md) - One-way migration from ai-rules/ layout ## Development diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..d1b3a88 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,57 @@ +# Migration to agents.md standard + +This guide describes how to migrate from the ai-rules–managed layout to the [agents.md](https://agents.md/) standard layout using `ai-rules migrate`. The migration is **one-way**: after migrating, the project no longer uses `ai-rules generate`, `ai-rules clean`, or `ai-rules status` for that content. + +## Prerequisites + +- Your project has an `ai-rules/` directory (created by `ai-rules init` or manually). +- **Back up** the `ai-rules/` directory if you want to keep the original layout; migration cannot be automatically reverted. + +## Command options + +| Option | Description | +|--------|-------------| +| `--nested-depth ` | Maximum nested directory depth to traverse (0 = current directory only). Same precedence as other commands: CLI overrides config file. | +| `--dry-run` | Print what would be done without writing or deleting. **Recommended first step.** | +| `--force` | Skip the confirmation prompt and run migration. | + +## Confirmation behavior + +- **With `--dry-run`**: No confirmation; the command only prints which directories would be migrated and what actions would be performed. +- **With `--force`**: No confirmation; migration runs immediately. +- **Without `--dry-run` or `--force`**: The command lists how many project(s) would be migrated, shows a warning that the change cannot be undone, and prompts: *"Proceed with migration? (y/N)"*. If you answer no, nothing is changed. + +**Recommendation:** Run `ai-rules migrate --dry-run` first to see the list of directories and actions, then run `ai-rules migrate` (and confirm) or `ai-rules migrate --force` to perform the migration. + +## What is written where + +| Outcome | Description | +|--------|-------------| +| **Root `AGENTS.md`** | A single self-contained markdown file at the project root. In **symlink mode** (single `ai-rules/AGENTS.md` with no YAML frontmatter), its content is a copy of that file. In **standard mode** (multiple `.md` rules with frontmatter), content is the inlined combination of all rules (no `@` file references). | +| **`.agents/skills/`** | The directory `ai-rules/skills/` is **moved** here. The whole tree is relocated; nothing is left under `ai-rules/`. | +| **`.agents/commands/`** | The directory `ai-rules/commands/` is **moved** here. Same as skills. | +| **Other `ai-rules/` subdirs** | Any other non-generated directories under `ai-rules/` (e.g. custom dirs) are moved into `.agents/` with the same name so `ai-rules/` can be fully removed. | +| **Root `.mcp.json`** | If `ai-rules/mcp.json` existed, it is moved to the project root as `.mcp.json` so tools that read MCP from root (e.g. Claude Code) can use it. | + +## What is removed + +- **Generated files and symlinks**: All outputs previously created by `ai-rules generate` are removed (e.g. `CLAUDE.md`, `GEMINI.md`, `.cursor/rules/*.mdc`, `firebender.json`, command/skill symlinks in `.claude`, `.cursor`, `.agents`, etc.), using the same logic as `ai-rules clean`. +- **`ai-rules/` directory**: After moving content out and running the equivalent of clean, the entire `ai-rules/` directory is deleted. + +## .gitignore behavior + +The block between `# AI Rules - Generated Files` and `# End AI Rules` is removed from the project’s `.gitignore`. No new entries are added for `.agents/`; the new layout is intended to be committed. + +## What is not migrated + +- **`ai-rules-config.yaml`** and **`ai-rules/firebender-overlay.json`** are not copied; they become obsolete. If you use Firebender or other tools that relied on generated paths, reconfigure them to point at root `AGENTS.md` if desired. +- **Cursor `.cursor/rules/*.mdc`** and **Firebender `firebender.json`** are not regenerated from the new layout; those tools would need to be pointed at root `AGENTS.md` manually if you want to use them with the new layout. + +## Nested / monorepo usage + +Migration runs per directory. Each directory that has its own `ai-rules/` gets its own root `AGENTS.md` and `.agents/` at that directory (e.g. `frontend/AGENTS.md`, `frontend/.agents/`). Use `--nested-depth` to control how many levels are traversed (same as `generate` and `clean`). + +## After migration + +- The project no longer uses ai-rules for that content. To use ai-rules again you would need to recreate `ai-rules/` and run `generate` (not automated). +- Tools that read `AGENTS.md` / `AGENTS(S).md` and `.agents/` (e.g. [agents.md](https://agents.md/)) can use the new layout directly. diff --git a/src/cli/args.rs b/src/cli/args.rs index 9dba849..acbbaec 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -32,6 +32,8 @@ pub enum Commands { Status(StatusArgs), /// Clean up generated files Clean(CleanArgs), + /// Migrate from ai-rules/ layout to agents.md standard (one-way) + Migrate(MigrateArgs), /// List all supported coding agents ListAgents, } @@ -113,6 +115,24 @@ pub struct CleanArgs { pub nested_depth_args: NestedDepthArgs, } +#[derive(Args)] +#[command(after_help = "Examples: + ai-rules migrate # Migrate current directory only (nested_depth 0) + ai-rules migrate --nested-depth 2 # Migrate ai-rules/ in nested directories + +Migrates from ai-rules/ layout to agents.md standard: writes AGENTS.md at project root, +moves ai-rules/skills and ai-rules/commands (and other ai-rules subdirs) into .agents/, +removes generated files and purges the ai-rules/ directory. One-way. Prompts for confirmation +unless --force or --dry-run. Run only when ready to adopt the standard. Use --dry-run first.")] +pub struct MigrateArgs { + #[command(flatten)] + pub nested_depth_args: NestedDepthArgs, + #[arg(long, help = "Print what would be done without writing or deleting")] + pub dry_run: bool, + #[arg(long, help = "Skip confirmation prompt and run migration (default: prompt before migrating)")] + pub force: bool, +} + #[derive(Debug, Clone)] pub struct ResolvedGenerateArgs { pub agents: Option>, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5a708f9..dc05968 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,7 +6,7 @@ mod tests; pub use args::*; -use crate::commands::{run_clean, run_generate, run_init, run_list_agents, run_status}; +use crate::commands::{run_clean, run_generate, run_init, run_list_agents, run_migrate, run_status}; use crate::config; use clap::Parser; @@ -43,6 +43,10 @@ pub fn run_cli() -> anyhow::Result<()> { let nested_depth = args.nested_depth_args.with_config(config.as_ref()); run_clean(¤t_dir, nested_depth, use_claude_skills) } + Some(Commands::Migrate(args)) => { + let nested_depth = args.nested_depth_args.with_config(config.as_ref()); + run_migrate(¤t_dir, nested_depth, args.dry_run, args.force) + } Some(Commands::ListAgents) => run_list_agents(use_claude_skills), None => { // If no command is provided and --summary is not used, show help diff --git a/src/commands/migrate.rs b/src/commands/migrate.rs new file mode 100644 index 0000000..e2b6a50 --- /dev/null +++ b/src/commands/migrate.rs @@ -0,0 +1,66 @@ +use crate::operations; +use crate::utils::file_utils; +use crate::utils::prompt_utils::prompt_yes_no; +use anyhow::Result; +use std::path::Path; + +pub fn run_migrate( + current_dir: &Path, + nested_depth: usize, + dry_run: bool, + force: bool, +) -> Result<()> { + // Discover all directories that would be migrated + let mut to_migrate = Vec::new(); + file_utils::traverse_project_directories(current_dir, nested_depth, 0, &mut |dir| { + if operations::migrate::should_migrate(dir) { + to_migrate.push(dir.to_path_buf()); + } + Ok(()) + })?; + + if to_migrate.is_empty() { + println!("No ai-rules/ directories found to migrate."); + return Ok(()); + } + + if dry_run { + println!("Dry run: would migrate {} project(s) to the agents.md standard:", to_migrate.len()); + for path in &to_migrate { + println!(" {}", path.display()); + } + } else if !force { + println!( + "This will migrate {} project(s) to the agents.md standard and remove ai-rules/ directories. This cannot be undone.", + to_migrate.len() + ); + if !prompt_yes_no("Proceed with migration?")? { + println!("Migration cancelled."); + return Ok(()); + } + } + + let mut results = Vec::new(); + for dir in &to_migrate { + let result = operations::migrate::run_migration_for_dir(dir, dry_run)?; + results.push(result); + } + + // Summary + let migrated: Vec<_> = results.iter().filter(|r| !r.skipped).collect(); + if migrated.is_empty() && !dry_run { + println!("No directories were migrated."); + } else if dry_run { + for r in &results { + if !r.skipped { + println!(" {}: would {}", r.path.display(), r.actions.join(", ")); + } + } + } else { + for r in &migrated { + println!("Migrated {}: {}", r.path.display(), r.actions.join(", ")); + } + } + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e2bdd54..c9e3973 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,12 +2,14 @@ mod clean; mod generate; mod init; mod list_agents; +mod migrate; mod status; pub use clean::run_clean; pub use generate::run_generate; pub use init::run_init; pub use list_agents::run_list_agents; +pub use migrate::run_migrate; pub use status::run_status; #[cfg(test)] diff --git a/src/operations/gitignore_updater.rs b/src/operations/gitignore_updater.rs index 037f883..86918ca 100644 --- a/src/operations/gitignore_updater.rs +++ b/src/operations/gitignore_updater.rs @@ -64,7 +64,7 @@ fn collect_all_gitignore_patterns( } } -fn remove_ai_rules_section(content: String) -> String { +pub(crate) fn remove_ai_rules_section(content: String) -> String { if let Some(start) = content.find("# AI Rules - Generated Files") { if let Some(end) = content.find("# End AI Rules") { let mut result = content; @@ -105,6 +105,20 @@ fn update_gitignore(current_dir: &Path, patterns: Vec) -> Result<()> { Ok(()) } +/// Removes the "# AI Rules - Generated Files" / "# End AI Rules" block from a .gitignore file at the given path. +/// No-op if the file does not exist or the section is not present. +pub fn remove_ai_rules_section_from_file(gitignore_path: &Path) -> Result<()> { + if !gitignore_path.exists() { + return Ok(()); + } + let content = fs::read_to_string(gitignore_path)?; + let new_content = remove_ai_rules_section(content.clone()); + if new_content != content { + fs::write(gitignore_path, new_content)?; + } + Ok(()) +} + pub fn remove_gitignore_section(current_dir: &Path, registry: &AgentToolRegistry) -> Result<()> { let gitignore_path = current_dir.join(".gitignore"); diff --git a/src/operations/migrate.rs b/src/operations/migrate.rs new file mode 100644 index 0000000..9072f40 --- /dev/null +++ b/src/operations/migrate.rs @@ -0,0 +1,323 @@ +use crate::agents::AgentToolRegistry; +use crate::constants::{ + AGENTS_MD_FILENAME, AI_RULE_SOURCE_DIR, CLAUDE_MCP_JSON, COMMANDS_DIR, + GENERATED_RULE_BODY_DIR, MCP_JSON, SKILLS_DIR, +}; +use crate::operations::body_generator; +use crate::operations::source_reader; +use crate::operations::{clean_generated_files, gitignore_updater}; +use anyhow::{Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Result of running migration for one directory. +#[derive(Debug)] +pub struct MigrationResult { + pub path: PathBuf, + pub skipped: bool, + pub actions: Vec, +} + +/// Returns true if `current_dir` contains an `ai-rules/` directory. +pub fn should_migrate(current_dir: &Path) -> bool { + current_dir.join(AI_RULE_SOURCE_DIR).is_dir() +} + +/// Builds the content for root AGENTS.md: symlink mode = copy of ai-rules/AGENTS.md; +/// standard mode = inlined content from all rules. +pub fn build_root_agents_md_content(current_dir: &Path) -> Result { + let ai_rules_dir = current_dir.join(AI_RULE_SOURCE_DIR); + if source_reader::detect_symlink_mode(current_dir) { + let agents_md = ai_rules_dir.join(AGENTS_MD_FILENAME); + let content = fs::read_to_string(&agents_md) + .with_context(|| format!("reading {}", agents_md.display()))?; + return Ok(content); + } + let source_files = source_reader::find_source_files(current_dir)?; + Ok(body_generator::generate_inlined_agents_content(&source_files)) +} + +/// Moves ai-rules/skills to .agents/skills. If .agents/skills exists, merges then removes source. +fn move_dir_into_agents( + current_dir: &Path, + subdir_name: &str, + agents_subdir: &str, +) -> Result<()> { + let src = current_dir.join(AI_RULE_SOURCE_DIR).join(subdir_name); + if !src.exists() || !src.is_dir() { + return Ok(()); + } + let agents_base = current_dir.join(".agents"); + let dest = agents_base.join(agents_subdir); + if !dest.exists() { + if let Some(p) = dest.parent() { + fs::create_dir_all(p)?; + } + fs::rename(&src, &dest).with_context(|| format!("moving {} to {}", src.display(), dest.display()))?; + return Ok(()); + } + // Dest exists: copy contents recursively then remove source + copy_dir_all(&src, &dest)?; + fs::remove_dir_all(&src)?; + Ok(()) +} + +/// Recursively copies src directory into dest (merge: existing files in dest are overwritten). +fn copy_dir_all(src: &Path, dest: &Path) -> Result<()> { + for entry in fs::read_dir(src)? { + let entry = entry?; + let path = entry.path(); + let name = path.file_name().unwrap_or_default(); + let dest_path = dest.join(name); + if path.is_dir() { + fs::create_dir_all(&dest_path)?; + copy_dir_all(&path, &dest_path)?; + } else { + fs::copy(&path, &dest_path)?; + } + } + Ok(()) +} + +/// Moves ai-rules/skills to .agents/skills. +pub fn move_skills_to_agents(current_dir: &Path) -> Result<()> { + move_dir_into_agents(current_dir, SKILLS_DIR, "skills") +} + +/// Moves ai-rules/commands to .agents/commands. +pub fn move_commands_to_agents(current_dir: &Path) -> Result<()> { + move_dir_into_agents(current_dir, COMMANDS_DIR, "commands") +} + +/// Moves any other non-generated subdirs of ai-rules/ into .agents/. +fn move_other_ai_rules_dirs_to_agents(current_dir: &Path) -> Result<()> { + let ai_rules_dir = current_dir.join(AI_RULE_SOURCE_DIR); + if !ai_rules_dir.exists() || !ai_rules_dir.is_dir() { + return Ok(()); + } + let skip_dirs: &[&str] = &[GENERATED_RULE_BODY_DIR, SKILLS_DIR, COMMANDS_DIR]; + for entry in fs::read_dir(&ai_rules_dir)? { + let entry = entry?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if skip_dirs.contains(&name) { + continue; + } + let dest = current_dir.join(".agents").join(name); + if !dest.exists() { + if let Some(p) = dest.parent() { + fs::create_dir_all(p)?; + } + fs::rename(&path, &dest)?; + } else { + copy_dir_all(&path, &dest)?; + fs::remove_dir_all(&path)?; + } + } + Ok(()) +} + +/// Copies or moves ai-rules/mcp.json to project root .mcp.json if present. +fn copy_or_move_mcp_to_root(current_dir: &Path) -> Result<()> { + let src = current_dir.join(AI_RULE_SOURCE_DIR).join(MCP_JSON); + if !src.exists() || !src.is_file() { + return Ok(()); + } + let dest = current_dir.join(CLAUDE_MCP_JSON); + let content = fs::read_to_string(&src)?; + fs::write(&dest, content)?; + fs::remove_file(&src)?; + Ok(()) +} + +/// Removes the ai-rules/ directory (purge after all content has been moved out). +fn remove_ai_rules_dir(current_dir: &Path) -> Result<()> { + let path = current_dir.join(AI_RULE_SOURCE_DIR); + if path.exists() { + fs::remove_dir_all(&path)?; + } + Ok(()) +} + +/// Runs the full migration for one directory. If !should_migrate, returns skipped. +/// When dry_run is true, no files are written or deleted; actions describe what would be done. +pub fn run_migration_for_dir(current_dir: &Path, dry_run: bool) -> Result { + if !should_migrate(current_dir) { + return Ok(MigrationResult { + path: current_dir.to_path_buf(), + skipped: true, + actions: vec![], + }); + } + + let mut actions = Vec::new(); + + // Build content before we move or remove anything (we need ai-rules/ to be present). + let content = build_root_agents_md_content(current_dir)?; + if dry_run { + actions.push("would write AGENTS.md".to_string()); + } + + let ai_rules = current_dir.join(AI_RULE_SOURCE_DIR); + let had_skills = ai_rules.join(SKILLS_DIR).exists(); + let had_commands = ai_rules.join(COMMANDS_DIR).exists(); + let had_mcp = ai_rules.join(MCP_JSON).exists(); + + if !dry_run { + move_skills_to_agents(current_dir)?; + if had_skills { + actions.push("moved skills to .agents/skills".to_string()); + } + move_commands_to_agents(current_dir)?; + if had_commands { + actions.push("moved commands to .agents/commands".to_string()); + } + move_other_ai_rules_dirs_to_agents(current_dir)?; + copy_or_move_mcp_to_root(current_dir)?; + if had_mcp { + actions.push("moved mcp.json to root .mcp.json".to_string()); + } + } else { + if had_skills { + actions.push("would move skills to .agents/skills".to_string()); + } + if had_commands { + actions.push("would move commands to .agents/commands".to_string()); + } + if had_mcp { + actions.push("would move mcp.json to root .mcp.json".to_string()); + } + } + + if !dry_run { + let registry = AgentToolRegistry::new(false); + let agents = registry.get_all_tool_names(); + clean_generated_files(current_dir, &agents, ®istry)?; + actions.push("cleaned generated files".to_string()); + remove_ai_rules_dir(current_dir)?; + actions.push("removed ai-rules/".to_string()); + // Write root AGENTS.md after clean so it is not removed as a "generated" file. + let root_agents = current_dir.join(AGENTS_MD_FILENAME); + fs::write(&root_agents, &content)?; + actions.push("wrote AGENTS.md".to_string()); + let gitignore_path = current_dir.join(".gitignore"); + gitignore_updater::remove_ai_rules_section_from_file(&gitignore_path)?; + actions.push("updated .gitignore".to_string()); + } else { + actions.push("would clean generated files and remove ai-rules/".to_string()); + } + + Ok(MigrationResult { + path: current_dir.to_path_buf(), + skipped: false, + actions, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::AGENTS_MD_FILENAME; + use crate::utils::test_utils::helpers::*; + use tempfile::TempDir; + + #[test] + fn test_should_migrate_no_ai_rules() { + let temp_dir = TempDir::new().unwrap(); + assert!(!should_migrate(temp_dir.path())); + } + + #[test] + fn test_should_migrate_has_ai_rules_dir() { + let temp_dir = TempDir::new().unwrap(); + create_file(temp_dir.path(), "ai-rules/.gitkeep", ""); + assert!(should_migrate(temp_dir.path())); + } + + #[test] + fn test_build_root_agents_md_content_symlink_mode() { + let temp_dir = TempDir::new().unwrap(); + let content = "# My agents\n\nPure markdown."; + create_file(temp_dir.path(), "ai-rules/AGENTS.md", content); + let result = build_root_agents_md_content(temp_dir.path()).unwrap(); + assert_eq!(result, content); + } + + const STANDARD_RULE: &str = r#"--- +description: Test rule +alwaysApply: true +--- +# Test +Body content."#; + + #[test] + fn test_build_root_agents_md_content_standard_mode() { + let temp_dir = TempDir::new().unwrap(); + create_file(temp_dir.path(), "ai-rules/rule1.md", STANDARD_RULE); + let result = build_root_agents_md_content(temp_dir.path()).unwrap(); + assert!(result.contains("Body content")); + assert!(!result.contains("@")); + } + + #[test] + fn test_run_migration_for_dir_symlink_mode_full() { + let temp_dir = TempDir::new().unwrap(); + let project_path = temp_dir.path(); + let symlink_content = "# Symlink AGENTS content"; + create_file(project_path, "ai-rules/AGENTS.md", symlink_content); + + let result = run_migration_for_dir(project_path, false).unwrap(); + assert!(!result.skipped); + assert!(project_path.join(AGENTS_MD_FILENAME).exists()); + assert_eq!( + std::fs::read_to_string(project_path.join(AGENTS_MD_FILENAME)).unwrap(), + symlink_content + ); + assert!(!project_path.join("ai-rules").exists()); + } + + #[test] + fn test_run_migration_for_dir_standard_mode_inlined() { + let temp_dir = TempDir::new().unwrap(); + let project_path = temp_dir.path(); + create_file(project_path, "ai-rules/rule1.md", STANDARD_RULE); + + let result = run_migration_for_dir(project_path, false).unwrap(); + assert!(!result.skipped); + let root_content = std::fs::read_to_string(project_path.join(AGENTS_MD_FILENAME)).unwrap(); + assert!(root_content.contains("Body content")); + assert!(!root_content.contains("@")); + assert!(!project_path.join("ai-rules").exists()); + } + + #[test] + fn test_run_migration_dry_run_leaves_ai_rules() { + let temp_dir = TempDir::new().unwrap(); + let project_path = temp_dir.path(); + create_file(project_path, "ai-rules/AGENTS.md", "# Content"); + + let result = run_migration_for_dir(project_path, true).unwrap(); + assert!(!result.skipped); + assert!(result.actions.iter().any(|a| a.contains("would"))); + assert!(project_path.join("ai-rules").exists()); + assert!(!project_path.join(AGENTS_MD_FILENAME).exists()); + } + + #[test] + fn test_run_migration_moves_skills_and_commands() { + let temp_dir = TempDir::new().unwrap(); + let project_path = temp_dir.path(); + create_file(project_path, "ai-rules/AGENTS.md", "# Agents"); + create_file(project_path, "ai-rules/skills/my-skill/SKILL.md", "skill"); + create_file(project_path, "ai-rules/commands/foo.md", "command"); + + let result = run_migration_for_dir(project_path, false).unwrap(); + assert!(!result.skipped); + assert!(project_path.join(".agents/skills/my-skill/SKILL.md").exists()); + assert!(project_path.join(".agents/commands/foo.md").exists()); + assert!(!project_path.join("ai-rules").exists()); + } +} diff --git a/src/operations/mod.rs b/src/operations/mod.rs index a8b2cf1..db8040a 100644 --- a/src/operations/mod.rs +++ b/src/operations/mod.rs @@ -6,6 +6,7 @@ pub mod generation_result; pub mod gitignore_updater; pub mod legacy_cleaner; pub mod mcp_reader; +pub mod migrate; pub mod optional_rules; pub mod skills_reader; pub mod source_reader;