diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4f3f03a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,176 @@ +# AGENTS.md + +Guidance for AI coding agents operating in this repository. + +## Project Summary + +**agentfiles** is a Rust CLI tool that installs agent files (skills, agents, commands) across multiple agentic coding providers (Claude Code, OpenCode, Codex, Cursor) from a unified `agentfiles.json` manifest. Rust 2024 edition, purely synchronous, no unsafe code. + +## Build / Lint / Test Commands + +```bash +cargo build # Build the project +cargo run # Build and run the binary +cargo test # Run ALL tests +cargo test # Run a single test by substring match +cargo test -- --exact # Run a single test by exact name +cargo clippy -- -D warnings # Lint (CI treats warnings as errors) +cargo fmt # Format code +cargo fmt -- --check # Check formatting without modifying +``` + +CI runs on every PR: `fmt --check`, `clippy -D warnings`, `test`, `build` across Linux/macOS/Windows. All four must pass. + +## Module Structure + +``` +src/ + lib.rs -- pub mod re-exports only + types.rs -- Core enums (FileScope, FileKind, FileStrategy, AgentProvider) + provider.rs -- Provider directory layout resolution, compatibility matrix + manifest.rs -- Manifest/FileMapping structs, JSON load/save (serde) + scanner.rs -- Auto-discovery of agent files from directory structures + installer.rs -- File installation (copy/symlink) to provider directories + git.rs -- Remote git: URL detection, @ref parsing, clone/cache + cli.rs -- CLI argument parsing (clap derive) + main.rs -- Binary entry point, command handlers (cmd_install, cmd_init, etc.) +``` + +Dependency flow: `types` <- `provider`, `manifest` <- `scanner`, `installer`. `git` and `cli` are standalone. `main` wires everything together via the lib crate. + +## Code Style Guidelines + +### Formatting + +Default `rustfmt` settings (no `.rustfmt.toml`). No configuration overrides. Just run `cargo fmt`. + +### Import Ordering + +Three groups separated by blank lines: + +```rust +// 1. Standard library +use std::fs; +use std::path::{Path, PathBuf}; + +// 2. External crates +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +// 3. Crate-internal modules +use crate::manifest::FileMapping; +use crate::types::{AgentProvider, FileKind}; +``` + +Exception: `types.rs` has no crate-internal imports so groups 1 and 2 may appear together. + +### Naming Conventions + +- **Functions**: `snake_case`. Public: `scan_agent_files`, `load_manifest`. Private helpers: `scan_kind_dir`, `normalize_url`. Command handlers in main: `cmd_install`, `cmd_init`. +- **Types/Structs/Enums**: `PascalCase`. Examples: `FileMapping`, `InstallResult`, `AgentProvider`. +- **Enum variants**: `PascalCase`. Examples: `FileScope::Project`, `FileKind::Skill`. +- **Constants**: `UPPER_SNAKE_CASE`. Example: `const KIND_DIRS: &[(&str, FileKind)]`. +- **Modules**: `snake_case`, flat structure (all in `src/`), no nested module directories. + +### Error Handling + +Uses `anyhow` exclusively. No custom error types. All `Result` types are `anyhow::Result`. Three patterns: + +```rust +// 1. Static context +let content = std::fs::read_to_string(path).context("failed to read manifest")?; + +// 2. Dynamic context with formatting +source.canonicalize() + .with_context(|| format!("failed to resolve: {}", source.display()))?; + +// 3. Early-return errors +anyhow::bail!("source file not found: {}", path.display()); +``` + +Error message style: lowercase, no trailing punctuation, descriptive. All `FromStr` impls use `type Err = anyhow::Error`. Never use `unwrap()` in production code -- only in tests. + +### Type Conventions + +- Derive set for domain types: `#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]` +- Serde: `#[serde(rename_all = "lowercase")]` on enums, `skip_serializing_if` for optional/default fields +- Builder-like pattern on `Manifest`: `Manifest::default().with_name(n).with_files(f)` +- No trait abstractions -- concrete types throughout. Standard traits (`Display`, `FromStr`, `Default`) implemented as needed. + +### Platform-Specific Code + +Gate with `#[cfg(unix)]` / `#[cfg(windows)]`. Used in `installer.rs` for symlink creation and in tests. + +### Output + +Uses `println!()` directly for user-facing output. No logging framework, no structured logging, no tracing. + +### Rust 2024 Edition Features + +Let-chains are used (e.g., in `scanner.rs`): +```rust +if entry_path.is_file() + && let Some(ext) = entry_path.extension() + && ext == "md" +{ +``` + +## Test Conventions + +### Location + +Inline `#[cfg(test)] mod tests` blocks within each module. No separate `tests/` directory, no test fixture files. + +### Organization + +Two patterns: + +```rust +// Flat (types.rs, provider.rs, scanner.rs): +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn descriptive_name() -> Result<()> { ... } +} + +// Nested sub-modules for grouping (manifest.rs, git.rs, installer.rs): +#[cfg(test)] +mod tests { + mod load_manifest { + use super::super::*; + #[test] + fn from_directory() -> Result<()> { ... } + } +} +``` + +### Test Patterns + +- **Return type**: Most tests return `Result<()>` and use `?` for propagation. Tests checking error cases use `assert!(result.is_err())`. +- **Filesystem**: Use `tempfile::TempDir` for all filesystem tests. Create test data inline -- no fixture files. +- **Platform gates**: Symlink tests use `#[cfg(unix)] #[test]`. +- **Naming**: Descriptive `snake_case`. No mandatory `test_` prefix. Examples: `save_and_roundtrip`, `install_command_skips_codex`, `shorthand_gets_https`. +- **Helpers**: Define local helper functions within test modules (e.g., `setup_skill`, `make_manifest`). + +### Running a Single Test + +```bash +cargo test save_and_roundtrip # Match by substring +cargo test tests::load_manifest::from_dir # Match nested module path +cargo test -p agentfiles save_and_roundtrip -- --exact # Exact match +``` + +## Design Principles + +1. **Flat module structure** -- all source in `src/*.rs`, no nested directories. +2. **Minimal dependencies** -- 5 runtime deps, 1 dev dep. Shell out to `git` rather than adding a git library. +3. **Single source of truth** -- `ProviderLayout` in `provider.rs` defines all provider config. Adding a provider means adding one layout entry. +4. **Compatibility matrix drives behavior** -- one manifest file, multiple providers handled automatically. +5. **No async** -- entirely synchronous. No tokio/async-std. +6. **Section separators** in larger files use comment banners: + ```rust + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + ``` diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 8edafaf..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,113 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -**agentfiles** is a CLI tool that unifies installation of agent files across multiple agentic coding providers. It reads a manifest (`agentfiles.json`) and installs skills, agents, and commands to the correct locations for each provider. - -## Build Commands - -```bash -cargo build # Build the project -cargo run # Build and run the binary -cargo test # Run all tests -cargo test test_name # Run a single test by name -cargo clippy # Lint -cargo fmt # Format code -cargo fmt -- --check # Check formatting without modifying -``` - -## Architecture - -### Core Concepts - -- **Manifest** (`agentfiles.json`): A JSON file declaring a package of agent files with metadata (name, version, author, repository) and a list of file mappings. -- **FileKind**: Categorizes files as `Skill`, `Agent`, or `Command` — determines the target subdirectory. -- **FileScope**: `Project` (relative to project root) or `Global` (relative to `$HOME`). -- **FileStrategy**: `Copy` (default) or `Link` (symlink) — how files are placed at the target. -- **AgentProvider**: `ClaudeCode`, `OpenCode`, `Codex`, `Cursor` — each with a compatibility matrix determining which FileKinds they support. - -### Provider Compatibility Matrix - -| Feature | Claude Code | OpenCode | Codex | Cursor | -|----------|:-----------:|:--------:|:-----:|:------:| -| Skills | Yes | Yes | Yes | Yes | -| Commands | Yes | Yes | No | Yes | -| Agents | Yes | Yes | No | Yes | - -### Target Directory Convention - -**Project scope** (relative to project root): - -| Provider | Skills | Commands | Agents | -|-------------|----------------------|------------------------|----------------------| -| Claude Code | `.claude/skills/` | `.claude/commands/` | `.claude/agents/` | -| OpenCode | `.opencode/skills/` | `.opencode/commands/` | `.opencode/agents/` | -| Codex | `.agents/skills/` | N/A | N/A | -| Cursor | `.cursor/skills/` | `.cursor/commands/` | `.cursor/agents/` | - -**Global scope** (`$HOME`-relative): - -| Provider | Skills | Commands | Agents | -|-------------|------------------------------|--------------------------------|------------------------------| -| Claude Code | `~/.claude/skills/` | `~/.claude/commands/` | `~/.claude/agents/` | -| OpenCode | `~/.config/opencode/skills/` | `~/.config/opencode/commands/` | `~/.config/opencode/agents/` | -| Codex | `~/.agents/skills/` | N/A | N/A | -| Cursor | `~/.cursor/skills/` | `~/.cursor/commands/` | `~/.cursor/agents/` | - -### Module Structure - -- `types.rs` — Core enums: `FileScope`, `FileKind`, `FileStrategy`, `AgentProvider` with compatibility matrix -- `provider.rs` — Provider target directory resolution with proper `$HOME` expansion via `dirs` crate -- `manifest.rs` — `Manifest` and `FileMapping` structs, JSON loading/saving via serde -- `scanner.rs` — Auto-discovery of agent files from directory structures -- `installer.rs` — File installation (copy/symlink) to provider-specific directories -- `git.rs` — Remote git repository support: URL detection, `@ref` parsing, clone/cache management -- `cli.rs` — CLI argument parsing with clap (install, init, scan, matrix commands) -- `main.rs` — Binary entry point wiring CLI to library functions -- `lib.rs` — Module re-exports - -### CLI Commands - -- `agentfiles install [source]` — Install files from a manifest or remote git repo - - `source` can be a local path, directory, or git URL (e.g., `github.com/org/repo@v1.0`) - - `--scope project|global` — Installation scope (default: project) - - `--providers claude-code,opencode,codex,cursor` — Target providers (default: all) - - `--strategy copy|link` — Override file placement strategy - - `--root ` — Project root directory -- `agentfiles init [path]` — Initialize a new agentfiles.json (auto-discovers existing files) -- `agentfiles scan [path]` — Scan a directory for agent files and optionally write manifest -- `agentfiles matrix` — Display the provider compatibility matrix - -### Manifest Format - -```json -{ - "name": "my-team-skills", - "version": "0.1.0", - "description": "Shared agent files", - "author": "Team Name", - "repository": "https://github.com/org/repo", - "files": [ - { - "path": "skills/review/SKILL.md", - "kind": "Skill" - }, - { - "path": "commands/deploy.md", - "kind": "Command", - "strategy": "Link" - } - ] -} -``` - -The `strategy` field is optional and defaults to `"Copy"`. It is omitted from JSON output when set to the default. - -### Design Decisions - -- **Kind-only file mappings**: The manifest declares files with just their `kind` (Skill/Agent/Command). The CLI handles multi-provider routing via the compatibility matrix — one skill file gets installed to all compatible providers automatically. -- **Scanner auto-discovery**: The scanner checks known provider directory prefixes (`.claude/`, `.opencode/`, `.agents/`, `.cursor/`, `.codex/`) as well as bare `skills/`, `commands/`, `agents/` directories. It deduplicates by name+kind. -- **Home directory resolution**: Uses the `dirs` crate for proper `$HOME` resolution instead of literal `~`. -- **Remote git installation**: Shells out to the system `git` CLI (inherits user's SSH/auth config, zero new dependencies). Clones are cached in `~/.cache/agentfiles//` and updated on subsequent installs. Supports `@ref` syntax for version pinning (e.g., `github.com/org/repo@v1.0`). Two modes: repo with `agentfiles.json` uses the manifest; repo without it auto-discovers files via the scanner. diff --git a/src/cli.rs b/src/cli.rs index 504183a..288ccbb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; use clap::{Parser, Subcommand}; +use crate::types::{AgentProvider, FileScope, FileStrategy}; + #[derive(Parser)] #[command( name = "agentfiles", @@ -23,16 +25,16 @@ pub enum Command { /// Installation scope: project or global #[arg(short, long, default_value = "project")] - scope: String, + scope: FileScope, /// Target providers (comma-separated). Defaults to all compatible providers. /// Options: claude-code, opencode, codex, cursor #[arg(short, long, value_delimiter = ',')] - providers: Option>, + providers: Option>, /// File placement strategy: copy or link (symlink). Can be overridden per-file in the manifest. #[arg(long)] - strategy: Option, + strategy: Option, /// Project root directory (for project scope installations) #[arg(long, default_value = ".")] diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..bab2a24 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,232 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +use crate::types::{AgentProvider, FileKind, FileScope, FileStrategy}; +use crate::{git, installer, manifest, scanner}; + +pub fn cmd_install( + source: String, + scope: FileScope, + providers: Option>, + strategy_override: Option, + root: PathBuf, +) -> Result<()> { + let providers = providers.unwrap_or_else(|| AgentProvider::ALL.to_vec()); + + // Resolve the source: either a remote git URL or a local path + let (manifest_dir, mut loaded) = if git::is_git_url(&source) { + resolve_remote_source(&source)? + } else { + resolve_local_source(&source)? + }; + + // Apply strategy override to all files that use the default + if let Some(strategy) = strategy_override { + for file in &mut loaded.files { + if file.strategy == FileStrategy::Copy { + file.strategy = strategy; + } + } + } + + let project_root = root + .canonicalize() + .context("could not resolve project root")?; + + let results = installer::install(&loaded, &providers, &scope, &project_root, &manifest_dir)?; + + if results.is_empty() { + println!("No files installed (no compatible provider/kind combinations found)."); + } else { + println!( + "Installed {} file(s) from '{}' (v{}):\n", + results.len(), + loaded.name, + loaded.version + ); + for r in &results { + println!( + " [{:>11}] {} -> {} ({})", + r.provider.to_string(), + r.source, + r.target, + r.strategy + ); + } + } + + Ok(()) +} + +/// Resolve a remote git URL to a local directory and manifest. +/// +/// Clones (or updates the cache of) the remote repository, then either +/// loads `agentfiles.json` from it or auto-discovers agent files via scanning. +fn resolve_remote_source(source: &str) -> Result<(PathBuf, manifest::Manifest)> { + let remote = git::parse_remote(source); + + let ref_display = remote + .git_ref + .as_deref() + .map(|r| format!(" @ {r}")) + .unwrap_or_default(); + println!("Resolving remote: {}{ref_display}", remote.url); + + let git_source = git::resolve_remote(&remote)?; + let local_path = git_source.local_path; + + println!("Cached at: {}\n", local_path.display()); + + // Try to load a manifest; fall back to scanning + let manifest_path = local_path.join("agentfiles.json"); + let loaded = if manifest_path.is_file() { + println!("Found agentfiles.json in remote repository."); + manifest::load_manifest(&local_path)? + } else { + println!("No agentfiles.json found — scanning for agent files..."); + let files = scanner::scan_agent_files(&local_path)?; + if files.is_empty() { + anyhow::bail!( + "no agentfiles.json and no agent files found in {}", + git_source.url + ); + } + println!("Discovered {} agent file(s) via scan.\n", files.len()); + + // Build a synthetic manifest from scanned files + let name = remote + .url + .rsplit('/') + .next() + .unwrap_or("remote") + .trim_end_matches(".git") + .to_string(); + + manifest::Manifest::default() + .with_name(name) + .with_repository(remote.url.clone()) + .with_files(files) + }; + + Ok((local_path, loaded)) +} + +/// Resolve a local path to a directory and manifest. +fn resolve_local_source(source: &str) -> Result<(PathBuf, manifest::Manifest)> { + let path = PathBuf::from(source); + + let manifest_dir = if path.is_dir() { + path.clone() + } else { + path.parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")) + }; + + let loaded = manifest::load_manifest(&path)?; + Ok((manifest_dir, loaded)) +} + +pub fn cmd_init(path: PathBuf, name: Option) -> Result<()> { + let dir = if path.is_dir() { + path.clone() + } else { + path.parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")) + }; + + let manifest_path = dir.join("agentfiles.json"); + if manifest_path.exists() { + anyhow::bail!( + "agentfiles.json already exists at {}", + manifest_path.display() + ); + } + + // Try to scan for existing files + let files = scanner::scan_agent_files(&dir).unwrap_or_default(); + + let pkg_name = name.unwrap_or_else(|| scanner::infer_name(&dir)); + + let m = manifest::Manifest::default() + .with_name(pkg_name) + .with_files(files); + + let output_path = manifest::save_manifest(&m, &dir)?; + println!("Created {}", output_path.display()); + + if !m.files.is_empty() { + println!("Discovered {} agent file(s):", m.files.len()); + for f in &m.files { + println!(" {} ({})", f.path.display(), f.kind); + } + } else { + println!( + "No agent files found. Add files to skills/, commands/, or agents/ and run 'agentfiles scan'." + ); + } + + Ok(()) +} + +pub fn cmd_scan(path: PathBuf, write: bool) -> Result<()> { + let files = scanner::scan_agent_files(&path)?; + + if files.is_empty() { + println!("No agent files found in {}", path.display()); + return Ok(()); + } + + println!("Found {} agent file(s):\n", files.len()); + for f in &files { + println!(" [{}] {}", f.kind, f.path.display()); + } + + if write { + let name = scanner::infer_name(&path); + let m = manifest::Manifest::default() + .with_name(name) + .with_files(files); + let output = manifest::save_manifest(&m, &path)?; + println!("\nWrote manifest to {}", output.display()); + } + + Ok(()) +} + +pub fn cmd_matrix() -> Result<()> { + let kinds = [FileKind::Skill, FileKind::Command, FileKind::Agent]; + let providers = AgentProvider::ALL; + + // Header + print!("{:<14}", "Provider"); + for kind in &kinds { + print!("{:<12}", kind.to_string()); + } + println!(); + + // Separator + print!("{}", "-".repeat(14)); + for _ in &kinds { + print!("{}", "-".repeat(12)); + } + println!(); + + // Rows + for provider in providers { + print!("{:<14}", provider.to_string()); + for kind in &kinds { + let supported = if provider.supports_kind(kind) { + "Yes" + } else { + "-" + }; + print!("{:<12}", supported); + } + println!(); + } + + Ok(()) +} diff --git a/src/git.rs b/src/git.rs index 11b0260..d05efc4 100644 --- a/src/git.rs +++ b/src/git.rs @@ -208,7 +208,7 @@ fn clone_repo(url: &str, target: &Path) -> Result<()> { } let output = Command::new("git") - .args(["clone", "--single-branch", url]) + .args(["clone", url]) .arg(target) .output() .with_context(|| format!("failed to run 'git clone {url}'"))?; @@ -237,8 +237,30 @@ fn fetch_repo(repo_dir: &Path) -> Result<()> { Ok(()) } +/// Validate a git ref to prevent flag injection. +/// +/// Rejects refs that could be interpreted as git command-line flags +/// or that contain path traversal sequences. +fn validate_git_ref(git_ref: &str) -> Result<()> { + if git_ref.is_empty() { + bail!("git ref cannot be empty"); + } + if git_ref.starts_with('-') { + bail!("git ref '{git_ref}' looks like a command-line flag — refusing for safety"); + } + if git_ref.contains("..") { + bail!("git ref '{git_ref}' contains '..' — refusing for safety"); + } + if git_ref.bytes().any(|b| b.is_ascii_control() || b == b' ') { + bail!("git ref '{git_ref}' contains whitespace or control characters"); + } + Ok(()) +} + /// Check out a specific ref (branch, tag, or commit hash). fn checkout_ref(repo_dir: &Path, git_ref: &str) -> Result<()> { + validate_git_ref(git_ref)?; + // First, try a detached checkout (works for tags and commit hashes) let output = Command::new("git") .args(["checkout", git_ref]) @@ -259,7 +281,7 @@ fn checkout_ref(repo_dir: &Path, git_ref: &str) -> Result<()> { .with_context(|| format!("failed to checkout remote branch '{git_ref}'"))?; if !output2.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = String::from_utf8_lossy(&output2.stderr); bail!("git checkout '{git_ref}' failed:\n{stderr}"); } @@ -304,10 +326,16 @@ fn reset_to_default_branch(repo_dir: &Path) -> Result<()> { } // Reset to match remote and pull latest - let _ = Command::new("git") + let reset_output = Command::new("git") .args(["reset", "--hard", &format!("origin/{default_branch}")]) .current_dir(repo_dir) - .output(); + .output() + .context("failed to run 'git reset'")?; + + if !reset_output.status.success() { + let stderr = String::from_utf8_lossy(&reset_output.stderr); + eprintln!("warning: git reset --hard failed: {stderr}"); + } Ok(()) } @@ -447,6 +475,44 @@ mod tests { } } + mod validate_git_ref_tests { + use super::*; + + #[test] + fn accepts_valid_refs() { + assert!(validate_git_ref("main").is_ok()); + assert!(validate_git_ref("v1.0").is_ok()); + assert!(validate_git_ref("feature/my-branch").is_ok()); + assert!(validate_git_ref("abc123def").is_ok()); + assert!(validate_git_ref("release/2.0.0").is_ok()); + } + + #[test] + fn rejects_flag_injection() { + assert!(validate_git_ref("--help").is_err()); + assert!(validate_git_ref("-c").is_err()); + assert!(validate_git_ref("--upload-pack=evil").is_err()); + } + + #[test] + fn rejects_path_traversal() { + assert!(validate_git_ref("../etc/passwd").is_err()); + assert!(validate_git_ref("foo/../bar").is_err()); + } + + #[test] + fn rejects_empty() { + assert!(validate_git_ref("").is_err()); + } + + #[test] + fn rejects_whitespace_and_control_chars() { + assert!(validate_git_ref("main branch").is_err()); + assert!(validate_git_ref("main\tbranch").is_err()); + assert!(validate_git_ref("main\0branch").is_err()); + } + } + mod normalize_url_tests { use super::*; diff --git a/src/installer.rs b/src/installer.rs index 5f49104..1bf0db0 100644 --- a/src/installer.rs +++ b/src/installer.rs @@ -51,7 +51,7 @@ pub fn install( let target_dir = provider.get_target_dir(scope, &file.kind, project_root)?; // Determine the target filename/path - let target_path = resolve_target_path(&source_path, &file.path, &target_dir)?; + let target_path = resolve_target_path(&file.path, &target_dir)?; // Ensure parent directories exist if let Some(parent) = target_path.parent() { @@ -123,10 +123,10 @@ pub fn install( } results.push(InstallResult { - provider: provider.clone(), + provider: *provider, source: file.path.display().to_string(), target: target_path.display().to_string(), - strategy: file.strategy.clone(), + strategy: file.strategy, kind: file.kind.to_string(), }); } @@ -142,11 +142,7 @@ pub fn install( /// /// For commands/agents (single .md files), we place the file directly /// (e.g., `deploy.md` -> `/deploy.md`). -fn resolve_target_path( - _source_path: &Path, - relative_path: &Path, - target_dir: &Path, -) -> Result { +fn resolve_target_path(relative_path: &Path, target_dir: &Path) -> Result { // Extract the meaningful part of the path. // If it's a SKILL.md inside a named directory, keep `/SKILL.md`. // If it's a command/agent .md file, keep just the filename. @@ -167,14 +163,19 @@ fn resolve_target_path( } } -/// Recursively copy a directory. +/// Recursively copy a directory, skipping symlinks to avoid infinite loops. fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { fs::create_dir_all(dst)?; for entry in fs::read_dir(src)? { let entry = entry?; + let file_type = entry.file_type()?; let src_path = entry.path(); let dst_path = dst.join(entry.file_name()); - if src_path.is_dir() { + + if file_type.is_symlink() { + // Skip symlinks to prevent infinite recursion from directory loops + continue; + } else if file_type.is_dir() { copy_dir_recursive(&src_path, &dst_path)?; } else { fs::copy(&src_path, &dst_path)?; @@ -284,7 +285,7 @@ mod tests { let results = install( &manifest, - &AgentProvider::all(), + AgentProvider::ALL, &FileScope::Project, dst_dir.path(), src_dir.path(), diff --git a/src/lib.rs b/src/lib.rs index abcb6a5..516ae7b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod cli; +pub mod commands; pub mod git; pub mod installer; pub mod manifest; diff --git a/src/main.rs b/src/main.rs index 3b44651..65c6725 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,6 @@ -use agentfiles::{cli, git, installer, manifest, scanner, types}; -use anyhow::{Context, Result}; +use agentfiles::{cli, commands}; +use anyhow::Result; use clap::Parser; -use types::{AgentProvider, FileScope, FileStrategy}; fn main() -> Result<()> { let args = cli::Cli::parse(); @@ -13,251 +12,9 @@ fn main() -> Result<()> { providers, strategy, root, - } => cmd_install(source, scope, providers, strategy, root), - cli::Command::Init { path, name } => cmd_init(path, name), - cli::Command::Scan { path, write } => cmd_scan(path, write), - cli::Command::Matrix => cmd_matrix(), + } => commands::cmd_install(source, scope, providers, strategy, root), + cli::Command::Init { path, name } => commands::cmd_init(path, name), + cli::Command::Scan { path, write } => commands::cmd_scan(path, write), + cli::Command::Matrix => commands::cmd_matrix(), } } - -fn cmd_install( - source: String, - scope_str: String, - providers_str: Option>, - strategy_str: Option, - root: std::path::PathBuf, -) -> Result<()> { - let scope: FileScope = scope_str.parse()?; - - // Parse providers or default to all - let providers = match providers_str { - Some(names) => names - .iter() - .map(|n| n.parse::()) - .collect::>>()?, - None => AgentProvider::all(), - }; - - // Parse global strategy override - let strategy_override: Option = strategy_str - .map(|s| s.parse::()) - .transpose()?; - - // Resolve the source: either a remote git URL or a local path - let (manifest_dir, mut loaded) = if git::is_git_url(&source) { - resolve_remote_source(&source)? - } else { - resolve_local_source(&source)? - }; - - // Apply strategy override to all files that use the default - if let Some(ref strategy) = strategy_override { - for file in &mut loaded.files { - if file.strategy == FileStrategy::Copy { - file.strategy = strategy.clone(); - } - } - } - - let project_root = root - .canonicalize() - .context("could not resolve project root")?; - - let results = installer::install(&loaded, &providers, &scope, &project_root, &manifest_dir)?; - - if results.is_empty() { - println!("No files installed (no compatible provider/kind combinations found)."); - } else { - println!( - "Installed {} file(s) from '{}' (v{}):\n", - results.len(), - loaded.name, - loaded.version - ); - for r in &results { - println!( - " [{:>11}] {} -> {} ({})", - r.provider.to_string(), - r.source, - r.target, - r.strategy - ); - } - } - - Ok(()) -} - -/// Resolve a remote git URL to a local directory and manifest. -/// -/// Clones (or updates the cache of) the remote repository, then either -/// loads `agentfiles.json` from it or auto-discovers agent files via scanning. -fn resolve_remote_source(source: &str) -> Result<(std::path::PathBuf, manifest::Manifest)> { - let remote = git::parse_remote(source); - - let ref_display = remote - .git_ref - .as_deref() - .map(|r| format!(" @ {r}")) - .unwrap_or_default(); - println!("Resolving remote: {}{ref_display}", remote.url); - - let git_source = git::resolve_remote(&remote)?; - let local_path = git_source.local_path; - - println!("Cached at: {}\n", local_path.display()); - - // Try to load a manifest; fall back to scanning - let manifest_path = local_path.join("agentfiles.json"); - let loaded = if manifest_path.is_file() { - println!("Found agentfiles.json in remote repository."); - manifest::load_manifest(&local_path)? - } else { - println!("No agentfiles.json found — scanning for agent files..."); - let files = scanner::scan_agent_files(&local_path)?; - if files.is_empty() { - anyhow::bail!( - "no agentfiles.json and no agent files found in {}", - git_source.url - ); - } - println!("Discovered {} agent file(s) via scan.\n", files.len()); - - // Build a synthetic manifest from scanned files - let name = remote - .url - .rsplit('/') - .next() - .unwrap_or("remote") - .trim_end_matches(".git") - .to_string(); - - manifest::Manifest::default() - .with_name(name) - .with_repository(remote.url.clone()) - .with_files(files) - }; - - Ok((local_path, loaded)) -} - -/// Resolve a local path to a directory and manifest. -fn resolve_local_source(source: &str) -> Result<(std::path::PathBuf, manifest::Manifest)> { - let path = std::path::PathBuf::from(source); - - let manifest_dir = if path.is_dir() { - path.clone() - } else { - path.parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| std::path::PathBuf::from(".")) - }; - - let loaded = manifest::load_manifest(&path)?; - Ok((manifest_dir, loaded)) -} - -fn cmd_init(path: std::path::PathBuf, name: Option) -> Result<()> { - let dir = if path.is_dir() { - path.clone() - } else { - path.parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| std::path::PathBuf::from(".")) - }; - - let manifest_path = dir.join("agentfiles.json"); - if manifest_path.exists() { - anyhow::bail!( - "agentfiles.json already exists at {}", - manifest_path.display() - ); - } - - // Try to scan for existing files - let files = scanner::scan_agent_files(&dir).unwrap_or_default(); - - let pkg_name = name.unwrap_or_else(|| scanner::infer_name(&dir)); - - let m = manifest::Manifest::default() - .with_name(pkg_name) - .with_files(files); - - let output_path = manifest::save_manifest(&m, &dir)?; - println!("Created {}", output_path.display()); - - if !m.files.is_empty() { - println!("Discovered {} agent file(s):", m.files.len()); - for f in &m.files { - println!(" {} ({})", f.path.display(), f.kind); - } - } else { - println!( - "No agent files found. Add files to skills/, commands/, or agents/ and run 'agentfiles scan'." - ); - } - - Ok(()) -} - -fn cmd_scan(path: std::path::PathBuf, write: bool) -> Result<()> { - let files = scanner::scan_agent_files(&path)?; - - if files.is_empty() { - println!("No agent files found in {}", path.display()); - return Ok(()); - } - - println!("Found {} agent file(s):\n", files.len()); - for f in &files { - println!(" [{}] {}", f.kind, f.path.display()); - } - - if write { - let name = scanner::infer_name(&path); - let m = manifest::Manifest::default() - .with_name(name) - .with_files(files); - let output = manifest::save_manifest(&m, &path)?; - println!("\nWrote manifest to {}", output.display()); - } - - Ok(()) -} - -fn cmd_matrix() -> Result<()> { - use types::FileKind; - - let kinds = [FileKind::Skill, FileKind::Command, FileKind::Agent]; - let providers = AgentProvider::all(); - - // Header - print!("{:<14}", "Provider"); - for kind in &kinds { - print!("{:<12}", kind.to_string()); - } - println!(); - - // Separator - print!("{}", "-".repeat(14)); - for _ in &kinds { - print!("{}", "-".repeat(12)); - } - println!(); - - // Rows - for provider in &providers { - print!("{:<14}", provider.to_string()); - for kind in &kinds { - let supported = if provider.supports_kind(kind) { - "Yes" - } else { - "-" - }; - print!("{:<12}", supported); - } - println!(); - } - - Ok(()) -} diff --git a/src/manifest.rs b/src/manifest.rs index b2aca74..9d44502 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -115,7 +115,7 @@ pub fn save_manifest(manifest: &Manifest, path: &Path) -> Result { if path.is_file() { bail!("cannot save manifest to a file, provide a directory path."); } - let content = serde_json::to_string_pretty(manifest)?; + let content = serde_json::to_string_pretty(manifest)? + "\n"; let output_path = path.join("agentfiles.json"); std::fs::write(&output_path, content).context("failed to write manifest")?; Ok(output_path) diff --git a/src/provider.rs b/src/provider.rs index cedbdad..c52a8af 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -104,12 +104,12 @@ impl AgentProvider { /// Used by the scanner to know which directory prefixes to look for /// when auto-discovering agent files. pub fn project_bases() -> Vec<&'static str> { - let mut bases: Vec<&'static str> = AgentProvider::all() + let mut seen = std::collections::HashSet::new(); + AgentProvider::ALL .iter() .map(|p| p.layout().project_base) - .collect(); - bases.dedup(); - bases + .filter(|b| seen.insert(*b)) + .collect() } /// Resolves the full target directory for a given scope and file kind. @@ -200,12 +200,14 @@ mod tests { ); // Codex does not support commands or agents - assert!(p - .get_target_dir(&FileScope::Project, &FileKind::Command, root) - .is_err()); - assert!(p - .get_target_dir(&FileScope::Project, &FileKind::Agent, root) - .is_err()); + assert!( + p.get_target_dir(&FileScope::Project, &FileKind::Command, root) + .is_err() + ); + assert!( + p.get_target_dir(&FileScope::Project, &FileKind::Agent, root) + .is_err() + ); } #[test] @@ -264,7 +266,7 @@ mod tests { #[test] fn supports_kind_derived_from_layout() { // All providers support skills - for provider in AgentProvider::all() { + for provider in AgentProvider::ALL { assert!(provider.supports_kind(&FileKind::Skill)); } diff --git a/src/scanner.rs b/src/scanner.rs index b2db2f2..9161b0c 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -110,7 +110,7 @@ fn scan_kind_dir( .to_path_buf(); mappings.push(FileMapping { path: rel_path, - kind: kind.clone(), + kind: *kind, strategy: FileStrategy::Copy, }); } diff --git a/src/types.rs b/src/types.rs index 027a013..f3753a9 100644 --- a/src/types.rs +++ b/src/types.rs @@ -3,7 +3,7 @@ use std::fmt; use std::str::FromStr; /// Scope determines where files are installed: relative to the project root or globally. -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)] #[serde(rename_all = "lowercase")] pub enum FileScope { Project, @@ -32,7 +32,7 @@ impl FromStr for FileScope { } /// The kind of agent file. Determines the target subdirectory. -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum FileKind { Skill, Agent, @@ -50,7 +50,7 @@ impl fmt::Display for FileKind { } /// How a file is placed at the target location. -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] pub enum FileStrategy { #[default] Copy, @@ -79,7 +79,7 @@ impl FromStr for FileStrategy { } /// Supported agentic coding tool providers. -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum AgentProvider { ClaudeCode, OpenCode, @@ -88,15 +88,13 @@ pub enum AgentProvider { } impl AgentProvider { - /// Returns all known providers. - pub fn all() -> Vec { - vec![ - AgentProvider::ClaudeCode, - AgentProvider::OpenCode, - AgentProvider::Codex, - AgentProvider::Cursor, - ] - } + /// All known providers as a compile-time constant slice. + pub const ALL: &[AgentProvider] = &[ + AgentProvider::ClaudeCode, + AgentProvider::OpenCode, + AgentProvider::Codex, + AgentProvider::Cursor, + ]; } impl fmt::Display for AgentProvider {