Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ categories = ["command-line-utilities", "development-tools"]
anyhow = "1.0.101"
clap = { version = "4.5.57", features = ["derive"] }
dirs = "6"
env_logger = "0.11.9"
log = "0.4.29"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"

Expand Down
3 changes: 3 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ use crate::types::{AgentProvider, FileScope, FileStrategy};
version
)]
pub struct Cli {
#[arg(short, long, global = true)]
pub verbose: bool,

#[command(subcommand)]
pub command: Command,
}
Expand Down
90 changes: 38 additions & 52 deletions src/commands.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
use std::path::PathBuf;

use anyhow::{Context, Result};
use log::debug;

use crate::manifest::{Dependency, FileMapping};
use crate::types::{AgentProvider, FileKind, FileScope, FileStrategy};
use crate::{git, installer, manifest, scanner};

// ---------------------------------------------------------------------------
// Install command
// ---------------------------------------------------------------------------

/// Options for the install command, collected from CLI arguments.
pub struct InstallOptions {
pub source: Option<String>,
Expand All @@ -29,6 +26,10 @@ pub struct InstallOptions {
/// - **With source**: resolves the source, scans it for agent files, installs
/// them, and (unless `no_save` is set) adds the source to `agentfiles.json`.
pub fn cmd_install(opts: InstallOptions) -> Result<()> {
debug!(
"cmd_install: source={:?}, scope={}, dry_run={}",
opts.source, opts.scope, opts.dry_run
);
let providers = opts
.providers
.unwrap_or_else(|| AgentProvider::ALL.to_vec());
Expand Down Expand Up @@ -76,6 +77,12 @@ fn install_from_manifest(
}

let loaded = manifest::load_manifest(project_root)?;
debug!(
"Loaded manifest '{}' v{} with {} dependencies",
loaded.name,
loaded.version,
loaded.dependencies.len()
);
if loaded.dependencies.is_empty() {
println!("No dependencies in agentfiles.json. Add one with 'agentfiles install <source>'.");
return Ok(());
Expand Down Expand Up @@ -118,6 +125,7 @@ fn install_from_source(
no_save: bool,
dry_run: bool,
) -> Result<()> {
debug!("Installing from source: {}", source);
let (source_dir, mut files) = resolve_source(source, None)?;

// Apply pick filter
Expand All @@ -126,6 +134,7 @@ fn install_from_source(
if files.is_empty() {
anyhow::bail!("no files matched the pick filter");
}
debug!("After pick filter: {} file(s) remaining", files.len());
}

// Apply strategy override (CLI flag takes highest precedence)
Expand Down Expand Up @@ -155,6 +164,7 @@ fn install_dependency(
dry_run: bool,
) -> Result<Vec<installer::InstallResult>> {
let source = dep.source();
debug!("Installing dependency: {}", source);
println!(" -> {source}");

let (source_dir, mut files) = resolve_source(source, dep.paths())?;
Expand Down Expand Up @@ -182,13 +192,14 @@ fn install_dependency(
return Ok(vec![]);
}

debug!(
"Dependency '{}': {} file(s) to install",
source,
files.len()
);
installer::install(&files, providers, scope, project_root, &source_dir, dry_run)
}

// ---------------------------------------------------------------------------
// Source resolution
// ---------------------------------------------------------------------------

/// Resolve a source (remote or local) to a local directory and scanned files.
///
/// When `custom_paths` is provided, the scanner uses those instead of the
Expand All @@ -197,6 +208,11 @@ fn resolve_source(
source: &str,
custom_paths: Option<&[manifest::PathMapping]>,
) -> Result<(PathBuf, Vec<FileMapping>)> {
debug!(
"Resolving source: {} (is_git={})",
source,
git::is_git_url(source)
);
if git::is_git_url(source) {
resolve_remote_source(source, custom_paths)
} else {
Expand All @@ -209,6 +225,7 @@ fn resolve_remote_source(
source: &str,
custom_paths: Option<&[manifest::PathMapping]>,
) -> Result<(PathBuf, Vec<FileMapping>)> {
debug!("Resolving remote source: {}", source);
let remote = git::parse_remote(source);

let ref_display = remote
Expand Down Expand Up @@ -237,6 +254,7 @@ fn resolve_local_source(
source: &str,
custom_paths: Option<&[manifest::PathMapping]>,
) -> Result<(PathBuf, Vec<FileMapping>)> {
debug!("Resolving local source: {}", source);
let path = PathBuf::from(source);
if !path.exists() {
anyhow::bail!("source path not found: {}", path.display());
Expand All @@ -254,17 +272,18 @@ fn resolve_local_source(
if files.is_empty() {
anyhow::bail!("no agent files found in {}", dir.display());
}
debug!(
"Local source resolved: {} file(s) in {}",
files.len(),
dir.display()
);

let canonical = dir
.canonicalize()
.context("could not resolve source path")?;
Ok((canonical, files))
}

// ---------------------------------------------------------------------------
// Manifest auto-save
// ---------------------------------------------------------------------------

/// Add a dependency to agentfiles.json, creating the file if it doesn't exist.
///
/// Normalizes the source URL and extracts any inline `@ref` into the
Expand All @@ -274,6 +293,7 @@ fn save_dependency(
pick: Option<&[String]>,
project_root: &std::path::Path,
) -> Result<()> {
debug!("Saving dependency: {}", source);
let manifest_path = project_root.join("agentfiles.json");

let mut loaded = if manifest_path.is_file() {
Expand Down Expand Up @@ -315,10 +335,6 @@ fn save_dependency(
Ok(())
}

// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------

fn print_results(results: &[installer::InstallResult], dry_run: bool) {
let prefix = if dry_run { "[dry-run] " } else { "" };

Expand All @@ -344,11 +360,8 @@ fn print_results(results: &[installer::InstallResult], dry_run: bool) {
}
}

// ---------------------------------------------------------------------------
// Init command
// ---------------------------------------------------------------------------

pub fn cmd_init(path: PathBuf, name: Option<String>) -> Result<()> {
debug!("cmd_init: path={}, name={:?}", path.display(), name);
let dir = if path.is_dir() {
path.clone()
} else {
Expand Down Expand Up @@ -378,11 +391,8 @@ pub fn cmd_init(path: PathBuf, name: Option<String>) -> Result<()> {
Ok(())
}

// ---------------------------------------------------------------------------
// Scan command
// ---------------------------------------------------------------------------

pub fn cmd_scan(source: String) -> Result<()> {
debug!("cmd_scan: source={}", source);
let files = if git::is_git_url(&source) {
let remote = git::parse_remote(&source);

Expand Down Expand Up @@ -414,17 +424,14 @@ pub fn cmd_scan(source: String) -> Result<()> {
Ok(())
}

// ---------------------------------------------------------------------------
// Remove command
// ---------------------------------------------------------------------------

pub fn cmd_remove(
source: String,
clean: bool,
scope: FileScope,
providers: Option<Vec<AgentProvider>>,
root: PathBuf,
) -> Result<()> {
debug!("cmd_remove: source={}, clean={}", source, clean);
let project_root = root
.canonicalize()
.context("could not resolve project root")?;
Expand Down Expand Up @@ -459,6 +466,7 @@ fn clean_installed_files(
providers: &[AgentProvider],
scope: &FileScope,
) -> Result<()> {
debug!("Cleaning installed files for source: {}", source);
// Resolve the source to get the file mappings
let scan_result = resolve_source(source, None);

Expand Down Expand Up @@ -489,6 +497,7 @@ fn clean_installed_files(

let target_path = target_dir.join(file_name);
if target_path.exists() || target_path.is_symlink() {
debug!("Removing {}", target_path.display());
if target_path.is_dir() && !target_path.is_symlink() {
std::fs::remove_dir_all(&target_path)
.with_context(|| format!("failed to remove {}", target_path.display()))?;
Expand All @@ -509,11 +518,8 @@ fn clean_installed_files(
Ok(())
}

// ---------------------------------------------------------------------------
// List command
// ---------------------------------------------------------------------------

pub fn cmd_list(root: PathBuf) -> Result<()> {
debug!("cmd_list: root={}", root.display());
let project_root = root
.canonicalize()
.context("could not resolve project root")?;
Expand Down Expand Up @@ -567,10 +573,6 @@ pub fn cmd_list(root: PathBuf) -> Result<()> {
Ok(())
}

// ---------------------------------------------------------------------------
// Matrix command
// ---------------------------------------------------------------------------

pub fn cmd_matrix() -> Result<()> {
let kinds = [FileKind::Skill, FileKind::Command, FileKind::Agent];
let providers = AgentProvider::ALL;
Expand Down Expand Up @@ -612,10 +614,6 @@ mod tests {
use std::fs;
use tempfile::TempDir;

// -----------------------------------------------------------------------
// Scan command tests
// -----------------------------------------------------------------------

#[test]
fn scan_local_with_files() -> Result<()> {
let dir = TempDir::new()?;
Expand Down Expand Up @@ -645,10 +643,6 @@ mod tests {
assert!(result.is_err());
}

// -----------------------------------------------------------------------
// Init command tests
// -----------------------------------------------------------------------

#[test]
fn init_creates_empty_manifest() -> Result<()> {
let dir = TempDir::new()?;
Expand All @@ -673,10 +667,6 @@ mod tests {
Ok(())
}

// -----------------------------------------------------------------------
// Install from manifest tests
// -----------------------------------------------------------------------

#[test]
fn install_no_source_without_manifest_errors() {
let dir = TempDir::new().unwrap();
Expand Down Expand Up @@ -714,10 +704,6 @@ mod tests {
Ok(())
}

// -----------------------------------------------------------------------
// Auto-save tests
// -----------------------------------------------------------------------

#[test]
fn install_source_auto_saves_to_manifest() -> Result<()> {
let src_dir = TempDir::new()?;
Expand Down
14 changes: 14 additions & 0 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::process::Command;
use std::sync::OnceLock;

use anyhow::{Context, Result, bail};
use log::debug;

/// Result of resolving a remote git source.
///
Expand Down Expand Up @@ -111,22 +112,30 @@ pub fn parse_remote(input: &str) -> ParsedRemote {
///
/// If a `git_ref` is provided, checks out that ref after clone/fetch.
pub fn resolve_remote(remote: &ParsedRemote) -> Result<GitSource> {
debug!(
"Resolving remote: url={}, ref={:?}",
remote.url, remote.git_ref
);
ensure_git_available()?;

let cache_dir = get_cache_dir(&remote.url)?;

if cache_dir.exists() {
debug!("Cache hit, fetching updates: {}", cache_dir.display());
// Update existing clone
fetch_repo(&cache_dir)?;
} else {
debug!("Cache miss, cloning to: {}", cache_dir.display());
// Fresh clone
clone_repo(&remote.url, &cache_dir)?;
}

// Check out the requested ref, or reset to the default branch HEAD
if let Some(ref git_ref) = remote.git_ref {
debug!("Checking out ref: {}", git_ref);
checkout_ref(&cache_dir, git_ref)?;
} else {
debug!("Resetting to default branch");
reset_to_default_branch(&cache_dir)?;
}

Expand Down Expand Up @@ -228,6 +237,7 @@ fn ensure_git_available() -> Result<()> {

/// Clone a repository into the cache directory.
fn clone_repo(url: &str, target: &Path) -> Result<()> {
debug!("Cloning {} into {}", url, target.display());
// Ensure parent directory exists
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
Expand All @@ -250,6 +260,7 @@ fn clone_repo(url: &str, target: &Path) -> Result<()> {

/// Fetch updates in an existing clone.
fn fetch_repo(repo_dir: &Path) -> Result<()> {
debug!("Fetching updates in {}", repo_dir.display());
let output = Command::new("git")
.args(["fetch", "--all", "--prune"])
.current_dir(repo_dir)
Expand Down Expand Up @@ -286,6 +297,7 @@ fn validate_git_ref(git_ref: &str) -> Result<()> {

/// Check out a specific ref (branch, tag, or commit hash).
fn checkout_ref(repo_dir: &Path, git_ref: &str) -> Result<()> {
debug!("Checking out ref '{}' in {}", git_ref, repo_dir.display());
validate_git_ref(git_ref)?;

// First, try a detached checkout (works for tags and commit hashes)
Expand Down Expand Up @@ -317,6 +329,7 @@ fn checkout_ref(repo_dir: &Path, git_ref: &str) -> Result<()> {

/// Reset the working tree to the latest commit on the default branch.
fn reset_to_default_branch(repo_dir: &Path) -> Result<()> {
debug!("Resetting to default branch in {}", repo_dir.display());
// Get the default branch name from the remote HEAD
let output = Command::new("git")
.args(["symbolic-ref", "refs/remotes/origin/HEAD", "--short"])
Expand All @@ -330,6 +343,7 @@ fn reset_to_default_branch(repo_dir: &Path) -> Result<()> {
} else {
"main".to_string()
};
debug!("Default branch: {}", branch);

let output = Command::new("git")
.args(["checkout", &branch])
Expand Down
Loading