diff --git a/README.md b/README.md index 8de025d..b9046e0 100644 --- a/README.md +++ b/README.md @@ -1,368 +1,91 @@ -# Smart Skills +# smart-skills -Agent skill management tool - manage and sync AI agent instructions for opencode, nvim, Cursor, and Claude Code. +CLI tool to manage AI agent skills for opencode, Cursor, and Claude Code. -## Features +Built for my own use. Open for contributions. -- **Validates skills for agent support**: Ensures proper format so your agents understand skills -- **Built with Rust**: Blazing fast - no waiting for npm -- **Zero dependencies**: Standalone binary - no Node.js, no npm chain -- **Open source**: Inspect every line yourself -- **Your skills, your machine**: No marketplace, no third-party auto-sync -- **Per-project + global config**: Team defaults via global, project overrides locally -- **Simple sync**: `smart-skills sync` to update skills - -## Installation - -### No npm/Node.js required! - -smart-skills is a standalone binary - no dependencies. - -### Via Cargo (recommended) +## Install ```bash cargo install smart-skills ``` -### Via Homebrew - -```bash -brew tap armedev/tap -brew install smart-skills -``` - -### Via Install Script - -```bash -curl -sL https://raw.githubusercontent.com/armedev/smart-skills/main/install.sh | bash -``` - -### From Source - -```bash -cargo build --release -cargo install --path . -``` - -## Security - -**We validate FORMAT. You validate CONTENT.** - -### What we do: -- Check skills have proper format (## headers, bullet points) -- Verify SKILL.md is not empty -- Parse frontmatter for agent compatibility - -### What YOU must do: -- **Manually review every skill before adding** -- **Use a raw text editor** - never trust rendered markdown -- Check for hidden comments, escape sequences, malicious code - -### Why this matters: -Your AI agent has permissions to: -- Run shell commands -- Access/modify files -- Read environment variables (API keys!) - -A malicious skill = instant breach. - -**Never add a skill you haven't reviewed yourself.** - -## Manual Review Required - -**Don't trust rendered markdown. Use a real editor.** - -```bash -# Find a skill you want -git clone https://github.com/awesome/skills.git - -# REVIEW IT FIRST - use raw editor -vim skills/my-skill/SKILL.md - -# Only if safe, copy to your source -cp -r skills/my-skill ./my-skills/ - -# Now add it -smart-skills add my-skill -``` - -Renderers can hide: -- HTML comments `` -- Escape sequences -- Code that looks safe but isn't - -**Your agent runs this. Verify it yourself.** - -## Why not skills.sh? - -- npm supply chain attacks -- Marketplace = trusting strangers -- Their "add" command fetches from internet = security risk -- We don't do automatic third-party - ## Quick Start ```bash -# Install (no npm needed!) -cargo install smart-skills -# or: brew install smart-skills - -# Initialize a project -smart-skills init - -# Add skills -smart-skills add planning - -# Sync to update -smart-skills sync -``` - -**For teams:** -1. Put skills in a shared location -2. Run `init` with your team's skill source -3. Team members run `sync` to get latest - ---- - -## Usage - -### Initialize a project - -```bash -smart-skills init -``` - -
-How init works (click to expand) - -The `init` command follows this logic: - -#### 1. No arguments provided - -| Scenario | Behavior | -|----------|----------| -| Global config exists | Uses global config as template, resolves source paths to absolute | -| Global config missing but global skills exist | Uses global skills dir as source, prompts for targets (interactive) or defaults to `agents` (non-interactive) | -| No global config, no global skills | Leaves sources empty, prompts for targets (interactive) or defaults to `agents` (non-interactive) | - -#### 2. With arguments - -| Argument | Behavior | -|----------|----------| -| `--skills-source ` | Use this skill source directory | -| `--targets a,c` | Use these targets | -| Both provided | Use both specified values | - -#### Path Resolution - -Skill source paths are resolved relative to current project directory. - -
- -### Quick Examples - -```bash -# Default initialization +# Create global config smart-skills init -# Use custom skill source -smart-skills init --skills-source ./my-skills - -# Specify targets explicitly -smart-skills init --targets agents,cursor -``` - -**Targets:** -- `agents` - opencode/nvim (`.agents/skills/`) -- `cursor` - Cursor IDE (`.cursor/rules/`) -- `claude` - Claude Code (`.claude/rules/`) - ---- - -### Add skills - -> Requires: `smart-skills init` to have been run first +# Add your skill source +smart-skills set-sources ./skills -```bash +# Install skills smart-skills add planning -smart-skills add planning execution # Add multiple -smart-skills add # Show available skills (requires init) -``` - -### Remove skills - -> Requires: `smart-skills init` to have been run first - -```bash -smart-skills remove planning -smart-skills remove # Show installed skills (requires init) -``` - -### List skills - -> Requires: `smart-skills init` to have been run first +smart-skills add code-review -```bash -smart-skills list -``` - -### Sync skills - -> Requires: `smart-skills init` to have been run first - -```bash +# Sync installed skills smart-skills sync ``` -### Check status +## Commands -> Requires: `smart-skills init` to have been run first +| Command | Description | +|---------|-------------| +| `add ` | Install a skill | +| `add --targets cursor,claude` | Install to specific targets | +| `add` | List available skills | +| `remove ` | Remove a skill | +| `remove --targets cursor` | Remove from specific targets | +| `list` | List available and installed | +| `sync` | Re-sync installed skills | +| `sync --targets cursor` | Sync to specific targets | +| `sync --remove-stale` | Remove stale skills | +| `clear` | Remove all installed | +| `clear --targets claude` | Clear from specific targets | +| `status` | Show status and validation | +| `config` | Show configuration | +| `init` | Create global config | +| `set-sources ` | Add skill sources (appends) | +| `set-sources --overwrite` | Replace all sources | -```bash -smart-skills status -``` - -### Config management +## Override Targets -> Requires: `smart-skills init` to have been run first +Use `--targets` to override global config for a single command: ```bash -smart-skills config # Show current config -smart-skills set-sources ./my-skills # Set skill sources +smart-skills add planning --targets cursor,claude +smart-skills sync --targets agents +smart-skills clear --targets claude ``` -### Clear all skills - -> Requires: `smart-skills init` to have been run first - -```bash -smart-skills clear -``` - ---- +Valid targets: `agents`, `cursor`, `claude` -## Global Config +## Config -
-What is global config? (click to expand) +Global config: `~/.config/smart-skills/config.json` -Global config allows you to define a standard skill configuration that new projects can inherit. - -### Location -``` -~/.config/smart-skills/config.json -``` - -### Example ```json { - "skill_sources": [ - { - "path": "~/my-skills", - "priority": 10 - } - ], - "install_targets": { - "agents": true, - "cursor": false, - "claude": false - } + "skill_sources": [{ "path": "~/my-skills", "priority": 10 }], + "install_targets": { "agents": true, "cursor": false, "claude": false } } ``` -### Benefits +## Targets -1. **One-time setup**: Configure once, use in all projects -2. **Team consistency**: Share the config file via dotfiles -3. **Path resolution**: Relative paths are automatically resolved when initializing new projects +- `agents` → `.agents/skills/` (opencode, nvim) +- `cursor` → `.cursor/rules/` (Cursor) +- `claude` → `.claude/rules/` (Claude Code) -### How it works - -- When you run `smart-skills init` without arguments, it uses global config as a template for your project -- Relative paths in global config are resolved to absolute paths (relative to global config directory) -- You can override either source or targets via CLI arguments - -### Setup Global Config - -```bash -# Create global config directory -mkdir -p ~/.config/smart-skills - -# Create config with your preferred settings -cat > ~/.config/smart-skills/config.json << 'EOF' -{ - "skill_sources": [ - { - "path": "~/my-skills", - "priority": 10 - } - ], - "install_targets": { - "agents": true, - "cursor": false, - "claude": false - } -} -EOF - -# Now any new project will use this config -cd ~/my-new-project -smart-skills init -``` - -
- ---- - -## Configuration - -### Project Config (`.smart-skills/config.json`) - -```json -{ - "skill_sources": [ - { - "path": "skills", - "priority": 10 - } - ], - "install_targets": { - "agents": true, - "cursor": false, - "claude": false - } -} -``` - -**Install Targets:** -- `agents`: Enable/disable `.agents/skills/` installation -- `cursor`: Enable/disable `.cursor/rules/` installation -- `claude`: Enable/disable `.claude/rules/` installation - -Edit this file and run `smart-skills sync` to apply changes. - -### Skill Sources - -- **path**: Path to skill directory (relative or absolute) -- **priority**: Higher priority sources are checked first (10 = highest) - ---- - -## Skill Structure - -Skills should be in directories with `SKILL.md` files: +## Skill Format ``` skills/ ├── planning/ │ └── SKILL.md -├── code-review/ -│ └── SKILL.md -└── ... ``` -### SKILL.md Format - +SKILL.md: ```markdown --- name: planning @@ -373,55 +96,18 @@ description: Plan before coding * Always understand the problem first * Break down into smaller tasks -* Consider edge cases ``` -The frontmatter is optional but helps agents understand skills better. +Frontmatter is optional. If missing, first bullet point becomes the description. ---- - -## Adding Custom Skills - -1. Create a skill directory: - ```bash - mkdir -p skills/my-custom-skill - ``` - -2. Add a `SKILL.md` file: - ```markdown - ## My Custom Skill +## Security - * Follow these guidelines - * For your team - ``` +We validate **FORMAT** (file exists, not empty, has ## headers or bullets). -3. Add it: - ```bash - smart-skills add my-custom-skill - ``` +You validate **CONTENT** (review skills manually before adding). ---- +Your agent can run shell commands and modify files. Don't add skills you haven't reviewed. -## Structure +## No Marketplace -``` -smart-skills/ -├── Cargo.toml -├── src/ -│ ├── main.rs -│ ├── cli/ -│ │ ├── commands.rs -│ │ ├── init.rs -│ │ ├── colors.rs -│ │ └── mod.rs -│ ├── skills/ -│ │ ├── mod.rs -│ │ ├── loader.rs -│ │ └── installer.rs -│ └── config/ -│ └── mod.rs -├── tests/ -│ ├── init_tests.rs -│ └── ... -└── README.md -``` +No automatic fetching from the internet. Your skills stay on your machine. diff --git a/src/cli/commands.rs b/src/cli/commands.rs index a671a53..3c4117e 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -1,27 +1,60 @@ use crate::cli::Colors; -use crate::config::{self, Config, SkillSource}; +use crate::config::{self, Config, InstallTargets, SkillSource, DEFAULT_PRIORITY}; use crate::skills::installer::SkillInstaller; use crate::skills::loader::SkillLoader; use std::fs; -fn is_initialized() -> bool { - config::project_config_path().exists() -} - fn ensure_initialized() -> Result<(), String> { - if !is_initialized() { - return Err("Not initialized. Run 'smart-skills init' first.".to_string()); + if !config::global_config_path().exists() { + return Err("No global config. Run 'smart-skills init' first.".to_string()); } Ok(()) } -pub fn add(skill_names: Vec) -> Result<(), String> { +fn parse_targets(targets: Option>) -> Result, String> { + let targets = targets.ok_or_else(|| "Target cannot be empty".to_string())?; + + if targets.is_empty() { + return Err("Target cannot be empty".to_string()); + } + + let mut install_targets = InstallTargets::default(); + + for t in &targets { + match t.as_str() { + "agents" => install_targets.agents = true, + "cursor" => install_targets.cursor = true, + "claude" => install_targets.claude = true, + _ => { + return Err(format!( + "Invalid target: {}. Valid: agents, cursor, claude", + t + )) + } + } + } + + Ok(Some(install_targets)) +} + +pub fn add(skills: Vec, targets: Option>) -> Result<(), String> { ensure_initialized()?; + let targets_override = parse_targets(targets)?; + if let Some(ref t) = targets_override { + println!( + " {}: agents={}, cursor={}, claude={}", + Colors::dim("Target"), + t.agents, + t.cursor, + t.claude + ); + } + let available = SkillLoader::load_available_skills(); let installed = SkillLoader::load_installed_skills(); - if skill_names.is_empty() { + if skills.is_empty() { println!( "{} ({})", Colors::header("Available skills"), @@ -38,17 +71,16 @@ pub fn add(skill_names: Vec) -> Result<(), String> { println!(" {}", Colors::dim(&skill.description)); } } - println!(); println!( - "To install: {}", - Colors::skill("smart-skills add ") + "\nTo install: {}", + Colors::skill("smart-skills add ") ); return Ok(()); } - for name in skill_names { + for name in skills { if let Some(skill) = available.get(&name) { - SkillInstaller::install(skill)?; + SkillInstaller::install(skill, targets_override.clone())?; println!( " {}: {}", Colors::success("Installed"), @@ -62,14 +94,24 @@ pub fn add(skill_names: Vec) -> Result<(), String> { ); } } - Ok(()) } -pub fn remove(skill_names: Vec) -> Result<(), String> { +pub fn remove(skills: Vec, targets: Option>) -> Result<(), String> { ensure_initialized()?; - if skill_names.is_empty() { + let targets_override = parse_targets(targets)?; + if let Some(ref t) = targets_override { + println!( + " {}: agents={}, cursor={}, claude={}", + Colors::dim("Target"), + t.agents, + t.cursor, + t.claude + ); + } + + if skills.is_empty() { let installed = SkillLoader::load_installed_skills(); println!( "{} ({})", @@ -86,11 +128,10 @@ pub fn remove(skill_names: Vec) -> Result<(), String> { return Ok(()); } - for name in &skill_names { - SkillInstaller::remove(name)?; + for name in &skills { + SkillInstaller::remove(name, targets_override.clone())?; println!(" {}: {}", Colors::success("Removed"), Colors::skill(name)); } - Ok(()) } @@ -105,16 +146,13 @@ pub fn list() -> Result<(), String> { Colors::header("Available skills"), available.len() ); - for (name, skill) in &available { + for name in available.keys() { let status = if installed.contains(name) { Colors::success("[installed]") } else { Colors::dim("") }; println!(" - {} {}", Colors::skill(name), status); - if !skill.description.is_empty() { - println!(" {}", Colors::dim(&skill.description)); - } } println!( @@ -125,61 +163,60 @@ pub fn list() -> Result<(), String> { for name in &installed { println!(" - {}", Colors::skill(name)); } - Ok(()) } -pub fn sync(remove_stale: bool) -> Result<(), String> { +pub fn sync(remove_stale: bool, targets: Option>) -> Result<(), String> { ensure_initialized()?; - println!("{}...", Colors::header("Syncing skills")); + let targets_override = parse_targets(targets)?; + if let Some(ref t) = targets_override { + println!( + " {}: agents={}, cursor={}, claude={}", + Colors::dim("Target"), + t.agents, + t.cursor, + t.claude + ); + } let available = SkillLoader::load_available_skills(); let installed = SkillLoader::load_installed_skills(); if installed.is_empty() && !remove_stale { println!(" {}", Colors::dim("Nothing to sync")); - println!("{}", Colors::success("Done!")); return Ok(()); } - let mut action_count = 0; + let mut count = 0; + // Remove stale from specified targets (CLI > config) if remove_stale { for name in &installed { if !available.contains_key(name) { - SkillInstaller::remove(name)?; + SkillInstaller::remove(name, targets_override.clone())?; println!( " {} stale: {}", Colors::success("Removed"), Colors::skill(name) ); - action_count += 1; + count += 1; } } - if action_count > 0 { - println!( - "{} {} stale skill(s)", - Colors::success("Removed"), - action_count - ); - } } for name in &installed { if let Some(skill) = available.get(name) { - SkillInstaller::install(skill)?; + SkillInstaller::install(skill, targets_override.clone())?; println!(" {}: {}", Colors::success("Synced"), Colors::skill(name)); - action_count += 1; + count += 1; } } - if action_count == 0 { + if count == 0 { println!(" {}", Colors::dim("Nothing to sync")); } - println!("{}", Colors::success("Done!")); - Ok(()) } @@ -189,7 +226,6 @@ pub fn status() -> Result<(), String> { println!("{}\n", Colors::header("Skill Status")); let installed = SkillLoader::load_installed_skills(); - if installed.is_empty() { println!(" {}", Colors::dim("No skills installed")); return Ok(()); @@ -201,46 +237,42 @@ pub fn status() -> Result<(), String> { } println!("\n{}\n", Colors::header("Validation")); + let result = SkillLoader::validate_skills(); - let validation = SkillLoader::validate_skills(); - - if validation.valid { + if result.valid { println!(" {}", Colors::success("All skills are valid!")); } else { println!( " {} {} error(s):", Colors::error("Found"), - validation.errors.len() + result.errors.len() ); - for error in &validation.errors { + for err in &result.errors { println!( " {} {}: {}", Colors::error("[ERROR]"), - Colors::skill(&error.skill), - error.message + Colors::skill(&err.skill), + err.message ); } } - if !validation.warnings.is_empty() { + if !result.warnings.is_empty() { println!( "\n {} ({}):", Colors::warning("Warnings"), - validation.warnings.len() + result.warnings.len() ); - for warning in &validation.warnings { - println!(" {}", Colors::warning(warning)); + for w in &result.warnings { + println!(" {}", Colors::warning(w)); } } println!("\n{}", Colors::header("Skill Sources")); - let sources = SkillLoader::get_skill_sources(); if sources.is_empty() { println!(" {}", Colors::dim("No skill sources configured")); - println!(" {}: smart-skills init", Colors::dim("Run")); } else { - println!(" {}", Colors::dim("Configured sources (priority order):")); for source in &sources { let exists = std::path::Path::new(&source.path).exists(); let status = if exists { @@ -256,64 +288,68 @@ pub fn status() -> Result<(), String> { ); } } - Ok(()) } -pub fn clear() -> Result<(), String> { +pub fn clear(targets: Option>) -> Result<(), String> { ensure_initialized()?; - let installed = SkillLoader::load_installed_skills(); + let targets_override = parse_targets(targets)?; + if let Some(ref t) = targets_override { + println!( + " {}: agents={}, cursor={}, claude={}", + Colors::dim("Target"), + t.agents, + t.cursor, + t.claude + ); + } + let installed = SkillLoader::load_installed_skills(); if installed.is_empty() { println!("{}", Colors::dim("Nothing to clear")); return Ok(()); } println!("{}...", Colors::header("Clearing all skills")); - for name in &installed { - SkillInstaller::remove(name)?; + SkillInstaller::remove(name, targets_override.clone())?; println!(" {}: {}", Colors::success("Removed"), Colors::skill(name)); } - println!("{}", Colors::success("Done!")); - Ok(()) } pub fn config_cmd() -> Result<(), String> { ensure_initialized()?; + let path = config::global_config_path(); println!("{}\n", Colors::header("Smart Skills Configuration")); + println!("{}", Colors::dim(&path.display().to_string())); - let project_config = config::project_config_path(); - println!("{}", Colors::dim(&project_config.display().to_string())); - - let cfg = Config::load(&project_config); + let cfg = Config::load(&path); println!(" {}:", Colors::header("Skill sources")); for source in &cfg.skill_sources { - let path = std::path::Path::new(&source.path); - let exists = path.exists(); - let count = if exists { - std::fs::read_dir(path) - .map(|entries| { - entries - .filter_map(|e| e.ok()) - .filter(|e| e.path().is_dir()) - .count() - }) - .unwrap_or(0) - } else { - 0 - }; + let p = std::path::Path::new(&source.path); + let exists = p.exists(); + let count = p + .exists() + .then(|| fs::read_dir(p).ok()) + .flatten() + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .count() + }) + .unwrap_or(0); let status = if exists { Colors::success("[ok]") } else { Colors::error("[missing]") }; println!( - " - {} (priority: {}, {} skill(s)) {}", + " - {} (priority: {}, {} skills) {}", Colors::dim(&source.path), source.priority, count, @@ -325,56 +361,46 @@ pub fn config_cmd() -> Result<(), String> { println!(" - agents: {}", cfg.install_targets.agents); println!(" - cursor: {}", cfg.install_targets.cursor); println!(" - claude: {}", cfg.install_targets.claude); - Ok(()) } -pub fn set_sources(paths: Vec) -> Result<(), String> { +pub fn set_sources(paths: Vec, overwrite: bool) -> Result<(), String> { if paths.is_empty() { println!( "{}", - Colors::dim("Usage: smart-skills set-sources ...") - ); - println!( - "{}", - Colors::dim("Example: smart-skills set-sources ./skills ~/my-skills") + Colors::dim("Usage: smart-skills set-sources ... [--overwrite]") ); return Ok(()); } ensure_initialized()?; - let mut sources: Vec = paths + let path = config::global_config_path(); + let mut cfg = Config::load(&path); + + let sources = if overwrite { vec![] } else { cfg.skill_sources }; + let max_priority = sources.iter().map(|s| s.priority).max().unwrap_or(0); + + let mut new_sources: Vec<_> = paths .into_iter() .enumerate() - .map(|(i, path)| SkillSource { - path, - priority: (10 - i as u8) * 10, + .map(|(i, p)| SkillSource { + path: config::resolve_path(&p).display().to_string(), + priority: max_priority + DEFAULT_PRIORITY * (i as u8 + 1), }) .collect(); - sources.sort_by(|a, b| b.priority.cmp(&a.priority)); - - // Load existing config to preserve targets - let project_config = config::project_config_path(); - let existing_config = Config::load(&project_config); - - let config = Config { - skill_sources: sources, - install_targets: existing_config.install_targets, - }; + let mut all = sources; + all.append(&mut new_sources); + all.sort_by(|a, b| b.priority.cmp(&a.priority)); + cfg.skill_sources = all; - fs::create_dir_all(config::project_config_dir()).map_err(|e| e.to_string())?; - config.save(&project_config)?; + fs::create_dir_all(config::global_config_dir()).map_err(|e| e.to_string())?; + cfg.save(&path)?; println!("Updated skill sources:"); - for source in &config.skill_sources { - println!( - " - {} (priority: {})", - Colors::skill(&source.path), - source.priority - ); + for s in &cfg.skill_sources { + println!(" - {} (priority: {})", Colors::skill(&s.path), s.priority); } - Ok(()) } diff --git a/src/cli/init.rs b/src/cli/init.rs index 228d537..998bca9 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -1,214 +1,48 @@ use crate::cli::Colors; -use crate::config::{self, Config, InstallTargets, SkillSource}; -use crate::skills::installer::SkillInstaller; -use crate::skills::loader::SkillLoader; +use crate::config::{self, Config, InstallTargets, SkillSource, DEFAULT_PRIORITY}; use std::fs; -use std::io::{stdout, IsTerminal}; -use std::path::PathBuf; -pub fn init(cli_source: String, cli_targets: Option>) -> Result<(), String> { - let has_cli_args = !cli_source.is_empty() || cli_targets.is_some(); - let global_config_exists = config::global_config_path().exists(); +pub fn init(source: String, targets: Option>, force: bool) -> Result<(), String> { + let path = config::global_config_path(); - let (source, targets) = if has_cli_args { - resolve_with_args(cli_source, cli_targets, global_config_exists)? - } else { - resolve_without_args(global_config_exists)? - }; - - let resolved_source = resolve_source_path(&source)?; - let config = create_config(&resolved_source, &targets); - setup_project(&config)?; - - if !resolved_source.is_empty() { - install_skills(&resolved_source)?; + if path.exists() && !force { + println!("{}", Colors::warning("Global config already exists")); + println!(" Use --force to overwrite"); + return Ok(()); } - println!("{}", Colors::success("Done!")); - Ok(()) -} - -fn resolve_with_args( - cli_source: String, - cli_targets: Option>, - global_config_exists: bool, -) -> Result<(String, Vec), String> { - let global_skills_path = config::global_skills_dir(); - let global_skills_exists = global_skills_path.exists(); - - if global_config_exists { - let global_cfg = Config::load(&config::global_config_path()); - - let source = if cli_source.is_empty() { - resolve_global_source_to_absolute() - } else { - cli_source - }; - - let targets = match cli_targets { - Some(t) => t, - None => targets_from_config(&global_cfg), - }; + let targets = targets.unwrap_or_else(|| vec!["agents".to_string()]); - Ok((source, targets)) - } else { - let source = if cli_source.is_empty() { - if global_skills_exists { - global_skills_path.to_string_lossy().to_string() - } else { - String::new() - } - } else { - cli_source - }; - - let targets = match cli_targets { - Some(t) => t, - None => resolve_targets_interactive_or_default(), - }; - - Ok((source, targets)) + if source.is_empty() && targets == vec!["agents".to_string()] { + println!("{}", Colors::dim("No arguments provided. Using defaults:")); + println!(" {}: (none)", Colors::dim("Skill source")); + println!(" {}: agents", Colors::dim("Targets")); + println!(); } -} -fn resolve_without_args(global_config_exists: bool) -> Result<(String, Vec), String> { - let global_skills_path = config::global_skills_dir(); - let global_skills_exists = global_skills_path.exists(); + fs::create_dir_all(config::global_config_dir()).map_err(|e| e.to_string())?; - if global_config_exists { - let global_cfg = Config::load(&config::global_config_path()); - let source = resolve_global_source_to_absolute(); - let targets = targets_from_config(&global_cfg); - Ok((source, targets)) - } else { - let source = if global_skills_exists { - global_skills_path.to_string_lossy().to_string() + let config = Config { + skill_sources: if source.is_empty() { + vec![] } else { - String::new() - }; - let targets = resolve_targets_interactive_or_default(); - Ok((source, targets)) - } -} - -fn resolve_global_source_to_absolute() -> String { - let global_cfg = Config::load(&config::global_config_path()); - global_cfg - .skill_sources - .first() - .map(|s| { - let path = PathBuf::from(&s.path); - if path.is_absolute() { - s.path.clone() - } else { - let resolved = config::global_config_dir().join(&s.path); - normalize_path(resolved) - } - }) - .unwrap_or_default() -} - -fn normalize_path(path: PathBuf) -> String { - path.components() - .filter(|c| !matches!(c, std::path::Component::CurDir)) - .collect::() - .to_string_lossy() - .to_string() -} - -fn targets_from_config(cfg: &Config) -> Vec { - let mut targets = Vec::new(); - if cfg.install_targets.agents { - targets.push("agents".to_string()); - } - if cfg.install_targets.cursor { - targets.push("cursor".to_string()); - } - if cfg.install_targets.claude { - targets.push("claude".to_string()); - } - if targets.is_empty() { - targets.push("agents".to_string()); - } - targets -} - -fn resolve_targets_interactive_or_default() -> Vec { - if stdout().is_terminal() { - prompt_for_targets().unwrap_or_else(|_| vec!["agents".to_string()]) - } else { - vec!["agents".to_string()] - } -} - -fn resolve_source_path(source: &str) -> Result { - if source.is_empty() { - return Ok(String::new()); - } - - let path = PathBuf::from(source); - - if path.is_absolute() { - return Ok(source.to_string()); - } - - let normalized = path - .components() - .filter(|c| !matches!(c, std::path::Component::CurDir)) - .collect::(); - - let cwd = std::env::current_dir().map_err(|e| e.to_string())?; - let cwd_path = cwd.join(&normalized); - - if cwd_path.exists() { - return Ok(cwd_path.to_string_lossy().to_string()); - } - - Ok(source.to_string()) -} - -fn create_config(source: &str, targets: &[String]) -> Config { - let skill_sources = if source.is_empty() { - Vec::new() - } else { - vec![SkillSource { - path: source.to_string(), - priority: 10, - }] - }; - - Config { - skill_sources, + vec![SkillSource { + path: config::resolve_path(&source).display().to_string(), + priority: DEFAULT_PRIORITY, + }] + }, install_targets: InstallTargets { agents: targets.contains(&"agents".to_string()), cursor: targets.contains(&"cursor".to_string()), claude: targets.contains(&"claude".to_string()), }, - } -} - -fn setup_project(config: &Config) -> Result<(), String> { - fs::create_dir_all(config::project_config_dir()).map_err(|e| e.to_string())?; - - if config.install_targets.agents { - fs::create_dir_all(config::agents_skills_dir()).map_err(|e| e.to_string())?; - } - if config.install_targets.cursor { - fs::create_dir_all(config::cursor_rules_dir()).map_err(|e| e.to_string())?; - } - if config.install_targets.claude { - fs::create_dir_all(config::claude_rules_dir()).map_err(|e| e.to_string())?; - } + }; - config.save(&config::project_config_path())?; + config.save(&path)?; + println!("{}", Colors::dim(&path.display().to_string())); if config.skill_sources.is_empty() { - println!("{}", Colors::warning("No skill source configured")); - println!( - " {}: {}", - Colors::dim("Add source with"), - Colors::skill("smart-skills set-sources ") - ); + println!(" {}", Colors::warning("No skill source set")); } else { println!( " {}: {}", @@ -216,85 +50,25 @@ fn setup_project(config: &Config) -> Result<(), String> { Colors::skill(&config.skill_sources[0].path) ); } + println!( + " {}: agents={}, cursor={}, claude={}", + Colors::dim("Targets"), + config.install_targets.agents, + config.install_targets.cursor, + config.install_targets.claude + ); + + println!("\n{}", Colors::success("Global config created.")); + println!( + " {}: {}", + Colors::dim("Set source with"), + Colors::skill("smart-skills set-sources ...") + ); + println!( + " {}: {}", + Colors::dim("Add skills with"), + Colors::skill("smart-skills add ") + ); Ok(()) } - -fn install_skills(source: &str) -> Result<(), String> { - let source_path = PathBuf::from(source); - - if !source_path.exists() { - println!( - " {}: Skill source directory does not exist", - Colors::warning("Warning") - ); - println!(" {}: mkdir -p {}", Colors::dim("Create it with"), source); - return Ok(()); - } - - let available = SkillLoader::load_available_skills(); - - if available.is_empty() { - println!( - " {}: No skills found in source", - Colors::warning("Warning") - ); - return Ok(()); - } - - for skill in available.values() { - SkillInstaller::install(skill)?; - println!( - " {}: {}", - Colors::success("Installed"), - Colors::skill(&skill.name) - ); - } - - Ok(()) -} - -fn prompt_for_targets() -> Result, String> { - println!("Select targets for skill installation:"); - println!(" [1] agents - opencode/nvim"); - println!(" [2] cursor - Cursor IDE"); - println!(" [3] claude - Claude Code"); - println!(" [4] all"); - println!(); - println!("Enter numbers separated by commas (default: 1):"); - - let mut input = String::new(); - std::io::stdin() - .read_line(&mut input) - .map_err(|e| e.to_string())?; - - let input = input.trim(); - - if input.is_empty() { - return Ok(vec!["agents".to_string()]); - } - - let mut targets = Vec::new(); - - for choice in input.split(',') { - match choice.trim() { - "1" => targets.push("agents".to_string()), - "2" => targets.push("cursor".to_string()), - "3" => targets.push("claude".to_string()), - "4" => { - return Ok(vec![ - "agents".to_string(), - "cursor".to_string(), - "claude".to_string(), - ]); - } - _ => return Err(format!("Invalid choice: {}", choice)), - } - } - - if targets.is_empty() { - targets.push("agents".to_string()); - } - - Ok(targets) -} diff --git a/src/config/mod.rs b/src/config/mod.rs index 2f30e4b..6b80045 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,6 +2,9 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +pub const SKILL_FILE: &str = "SKILL.md"; +pub const DEFAULT_PRIORITY: u8 = 10; + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Config { pub skill_sources: Vec, @@ -45,46 +48,24 @@ impl Config { pub fn save(&self, path: &PathBuf) -> Result<(), String> { let content = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; - fs::write(path, content).map_err(|e| e.to_string())?; - Ok(()) + fs::write(path, content).map_err(|e| e.to_string()) } } -#[allow(dead_code)] pub fn global_config_path() -> PathBuf { - global_config_dir().join("config.json") -} - -pub fn global_config_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".config") .join("smart-skills") + .join("config.json") } -pub fn global_skills_dir() -> PathBuf { - global_config_dir().join("skills") -} - -#[allow(dead_code)] -pub fn project_skills_dir() -> PathBuf { - PathBuf::from("skills") -} - -pub fn project_config_dir() -> PathBuf { - PathBuf::from(".smart-skills") -} - -pub fn project_config_path() -> PathBuf { - project_config_dir().join("config.json") -} - -pub fn agents_dir() -> PathBuf { - PathBuf::from(".agents") +pub fn global_config_dir() -> PathBuf { + global_config_path().parent().unwrap().to_path_buf() } pub fn agents_skills_dir() -> PathBuf { - agents_dir().join("skills") + PathBuf::from(".agents").join("skills") } pub fn cursor_rules_dir() -> PathBuf { @@ -95,6 +76,17 @@ pub fn claude_rules_dir() -> PathBuf { PathBuf::from(".claude").join("rules") } +pub fn resolve_path(path: &str) -> PathBuf { + let p = PathBuf::from(path); + if p.is_relative() { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(p) + } else { + p + } +} + #[cfg(test)] mod tests { use super::*; @@ -117,7 +109,7 @@ mod tests { let config = Config { skill_sources: vec![SkillSource { path: "test".to_string(), - priority: 10, + priority: DEFAULT_PRIORITY, }], install_targets: InstallTargets { agents: true, @@ -131,27 +123,28 @@ mod tests { assert_eq!(loaded.skill_sources.len(), 1); assert_eq!(loaded.skill_sources[0].path, "test"); - assert_eq!(loaded.skill_sources[0].priority, 10); + assert_eq!(loaded.skill_sources[0].priority, DEFAULT_PRIORITY); assert!(!loaded.install_targets.cursor); } #[test] fn test_config_load_nonexistent() { - let config_path = PathBuf::from("/nonexistent/path/config.json"); - let config = Config::load(&config_path); - // Should return default config + let config = Config::load(&PathBuf::from("/nonexistent/config.json")); assert!(config.skill_sources.is_empty()); } #[test] - fn test_path_helpers() { - // Just make sure these don't panic - let _ = global_config_path(); - let _ = global_config_dir(); - let _ = global_skills_dir(); - let _ = project_config_path(); - let _ = agents_skills_dir(); - let _ = cursor_rules_dir(); - let _ = claude_rules_dir(); + fn test_resolve_path_relative() { + let result = resolve_path("./skills"); + assert!(result.is_absolute()); + assert!(result.to_string_lossy().ends_with("skills")); + } + + #[test] + fn test_resolve_path_absolute() { + assert_eq!( + resolve_path("/absolute/path"), + PathBuf::from("/absolute/path") + ); } } diff --git a/src/main.rs b/src/main.rs index 24af534..1a03517 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,47 +15,73 @@ struct Cli { #[derive(Subcommand)] enum Commands { - #[command(about = "Initialize project with skill sources and targets")] + #[command(about = "Set up global config")] Init { #[arg(long = "skills-source", default_value = "")] source: String, #[arg(long = "targets", value_delimiter = ',')] targets: Option>, + #[arg(long = "force")] + force: bool, + }, + #[command(about = "Add skills")] + Add { + skills: Vec, + #[arg(long = "targets", value_delimiter = ',')] + targets: Option>, }, - #[command(about = "Add skills to your project")] - Add { skills: Vec }, #[command(about = "Remove installed skills")] - Remove { skills: Vec }, + Remove { + skills: Vec, + #[arg(long = "targets", value_delimiter = ',')] + targets: Option>, + }, #[command(about = "List available and installed skills")] List, #[command(about = "Sync skills from sources to targets")] Sync { #[arg(long = "remove-stale")] remove_stale: bool, + #[arg(long = "targets", value_delimiter = ',')] + targets: Option>, }, #[command(about = "Show skill status and validation")] Status, #[command(about = "Remove all installed skills")] - Clear, + Clear { + #[arg(long = "targets", value_delimiter = ',')] + targets: Option>, + }, #[command(about = "Display current configuration")] Config, #[command(about = "Set skill source directories")] - SetSources { paths: Vec }, + SetSources { + paths: Vec, + #[arg(long, short)] + overwrite: bool, + }, } fn main() { let cli = Cli::parse(); let result = match cli.command { - Commands::Init { source, targets } => cli::init(source, targets), - Commands::Add { skills } => cli::add(skills), - Commands::Remove { skills } => cli::remove(skills), + Commands::Init { + source, + targets, + force, + } => cli::init(source, targets, force), + Commands::Add { skills, targets } => cli::add(skills, targets), + Commands::Remove { skills, targets } => cli::remove(skills, targets), Commands::List => cli::list(), - Commands::Sync { remove_stale } => cli::sync(remove_stale), + Commands::Sync { + remove_stale, + targets, + } => cli::sync(remove_stale, targets), Commands::Status => cli::status(), - Commands::Clear => cli::clear(), + Commands::Clear { targets } => cli::clear(targets), Commands::Config => cli::config_cmd(), - Commands::SetSources { paths } => cli::set_sources(paths), + Commands::SetSources { paths, overwrite } => cli::set_sources(paths, overwrite), }; if let Err(e) = result { diff --git a/src/skills/generator.rs b/src/skills/generator.rs deleted file mode 100644 index 044669c..0000000 --- a/src/skills/generator.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crate::config; -use crate::skills::Skill; -use std::collections::HashMap; -use std::fs; - -pub struct AgentsMdGenerator; - -impl AgentsMdGenerator { - pub fn generate(skills: &HashMap) -> Result { - let mut content = String::new(); - - content.push_str("# Agent Instructions\n\n"); - content.push_str("This file is auto-generated by smart-skills. Do not edit manually.\n\n"); - - let mut sorted_skills: Vec<_> = skills.values().collect(); - sorted_skills.sort_by(|a, b| a.name.cmp(&b.name)); - - for skill in sorted_skills { - content.push_str(&format!("## {}\n\n", skill.name)); - content.push_str(&skill.content); - content.push_str("\n\n---\n\n"); - } - - Ok(content) - } - - pub fn write(skills: &HashMap) -> Result<(), String> { - let content = Self::generate(skills)?; - fs::write(config::agents_md_path(), content).map_err(|e| e.to_string())?; - Ok(()) - } -} diff --git a/src/skills/installer.rs b/src/skills/installer.rs index cb9215d..1eaa51f 100644 --- a/src/skills/installer.rs +++ b/src/skills/installer.rs @@ -1,62 +1,40 @@ -use crate::config::{self, Config}; +use crate::config::{self, Config, InstallTargets, SKILL_FILE}; use crate::skills::Skill; use std::fs; -#[allow(dead_code)] pub struct SkillInstaller; -#[allow(dead_code)] impl SkillInstaller { - pub fn install(skill: &Skill) -> Result<(), String> { + pub fn install(skill: &Skill, targets: Option) -> Result<(), String> { let cfg = Self::load_config(); + let targets = targets.unwrap_or(cfg.install_targets); - if cfg.install_targets.agents { - Self::install_to_agents(skill)?; + if targets.agents { + Self::install_skill(skill, &config::agents_skills_dir(), false)?; } - if cfg.install_targets.cursor { - Self::install_to_cursor(skill)?; + if targets.cursor { + Self::install_skill(skill, &config::cursor_rules_dir(), true)?; } - if cfg.install_targets.claude { - Self::install_to_claude(skill)?; + if targets.claude { + Self::install_skill(skill, &config::claude_rules_dir(), true)?; } Ok(()) } - fn load_config() -> Config { - let project_config = config::project_config_path(); - if project_config.exists() { - return Config::load(&project_config); - } - Config::default() - } - - fn install_to_agents(skill: &Skill) -> Result<(), String> { - let target_dir = config::agents_skills_dir().join(&skill.name); - fs::create_dir_all(&target_dir).map_err(|e| e.to_string())?; - - let target_path = target_dir.join("SKILL.md"); + fn install_skill( + skill: &Skill, + dir: &std::path::Path, + single_file: bool, + ) -> Result<(), String> { + fs::create_dir_all(dir).map_err(|e| e.to_string())?; - let content = if Self::has_frontmatter(&skill.content) { - skill.content.clone() + let path = if single_file { + dir.join(format!("{}.md", skill.name)) } else { - format!( - "---\nname: {}\ndescription: {}\n---\n\n{}", - skill.name, skill.description, skill.content - ) + dir.join(&skill.name).join(SKILL_FILE) }; - fs::write(&target_path, content).map_err(|e| e.to_string())?; - - Ok(()) - } - - fn install_to_cursor(skill: &Skill) -> Result<(), String> { - let target_dir = config::cursor_rules_dir(); - fs::create_dir_all(&target_dir).map_err(|e| e.to_string())?; - - let target_path = target_dir.join(format!("{}.md", skill.name)); - - let content = if Self::has_frontmatter(&skill.content) { + let content = if skill.content.starts_with("---") { skill.content.clone() } else { format!( @@ -65,84 +43,73 @@ impl SkillInstaller { ) }; - fs::write(&target_path, content).map_err(|e| e.to_string())?; - - Ok(()) + fs::write(path, content).map_err(|e| e.to_string()) } - fn install_to_claude(skill: &Skill) -> Result<(), String> { - let target_dir = config::claude_rules_dir(); - fs::create_dir_all(&target_dir).map_err(|e| e.to_string())?; - - let target_path = target_dir.join(format!("{}.md", skill.name)); - - let content = if Self::has_frontmatter(&skill.content) { - skill.content.clone() + fn load_config() -> Config { + let path = config::global_config_path(); + if path.exists() { + Config::load(&path) } else { - format!( - "---\nname: {}\ndescription: {}\n---\n\n{}", - skill.name, skill.description, skill.content - ) - }; - - fs::write(&target_path, content).map_err(|e| e.to_string())?; - - Ok(()) - } - - fn has_frontmatter(content: &str) -> bool { - content.starts_with("---") + Config::default() + } } - pub fn remove(skill_name: &str) -> Result<(), String> { + pub fn remove(name: &str, targets: Option) -> Result<(), String> { let cfg = Self::load_config(); + let targets = targets.unwrap_or(cfg.install_targets); - if cfg.install_targets.agents { - Self::remove_from_agents(skill_name)?; + if targets.agents { + let dir = config::agents_skills_dir().join(name); + if dir.exists() { + fs::remove_dir_all(dir).map_err(|e| e.to_string())?; + } } - if cfg.install_targets.cursor { - Self::remove_from_cursor(skill_name)?; + if targets.cursor { + let path = config::cursor_rules_dir().join(format!("{}.md", name)); + let _ = fs::remove_file(path); } - if cfg.install_targets.claude { - Self::remove_from_claude(skill_name)?; + if targets.claude { + let path = config::claude_rules_dir().join(format!("{}.md", name)); + let _ = fs::remove_file(path); } Ok(()) } - fn remove_from_agents(skill_name: &str) -> Result<(), String> { - let target_dir = config::agents_skills_dir().join(skill_name); - if target_dir.exists() { - fs::remove_dir_all(&target_dir).map_err(|e| e.to_string())?; - } - Ok(()) - } - - fn remove_from_cursor(skill_name: &str) -> Result<(), String> { - let target_path = config::cursor_rules_dir().join(format!("{}.md", skill_name)); - if target_path.exists() { - fs::remove_file(&target_path).map_err(|e| e.to_string())?; - } - Ok(()) - } - - fn remove_from_claude(skill_name: &str) -> Result<(), String> { - let target_path = config::claude_rules_dir().join(format!("{}.md", skill_name)); - if target_path.exists() { - fs::remove_file(&target_path).map_err(|e| e.to_string())?; - } - Ok(()) - } - - pub fn is_installed(skill_name: &str) -> bool { + #[allow(dead_code)] + pub fn is_installed(name: &str) -> bool { config::agents_skills_dir() - .join(skill_name) - .join("SKILL.md") + .join(name) + .join(SKILL_FILE) .exists() } - pub fn sync_all(skills: &[Skill]) -> Result<(), String> { - for skill in skills { - Self::install(skill)?; + #[allow(dead_code)] + pub fn remove_all_from_config() -> Result<(), String> { + let cfg = Self::load_config(); + let targets = cfg.install_targets; + + if targets.agents { + let dir = config::agents_skills_dir(); + if dir.exists() { + fs::remove_dir_all(dir).map_err(|e| e.to_string())?; + } + } + if targets.cursor { + let dir = config::cursor_rules_dir(); + if dir.exists() { + for e in fs::read_dir(dir).map_err(|e| e.to_string())?.flatten() { + let _ = fs::remove_file(e.path()); + } + } + } + if targets.claude { + let dir = config::claude_rules_dir(); + if dir.exists() { + for e in fs::read_dir(dir).map_err(|e| e.to_string())?.flatten() { + let _ = fs::remove_file(e.path()); + } + } } Ok(()) } @@ -153,53 +120,12 @@ mod tests { use super::*; #[test] - fn test_has_frontmatter_true() { - let content = "---\ndescription: test\n---\n\n## Content"; - assert!(SkillInstaller::has_frontmatter(content)); - } - - #[test] - fn test_has_frontmatter_false() { - let content = "## Content\n\n* Bullet point"; - assert!(!SkillInstaller::has_frontmatter(content)); - } - - #[test] - fn test_has_frontmatter_empty() { - assert!(!SkillInstaller::has_frontmatter("")); + fn test_remove_nonexistent() { + assert!(SkillInstaller::remove("nonexistent", None).is_ok()); } #[test] fn test_is_installed_not_installed() { - // Should return false when skill is not installed - assert!(!SkillInstaller::is_installed("nonexistent-skill")); - } - - #[test] - fn test_remove_nonexistent_skill() { - // Should not fail when removing non-existent skill - let result = SkillInstaller::remove("nonexistent-skill"); - assert!(result.is_ok()); - } - - #[test] - fn test_remove_from_agents_nonexistent() { - // Should not fail when removing from non-existent directory - let result = SkillInstaller::remove_from_agents("test-skill"); - assert!(result.is_ok()); - } - - #[test] - fn test_remove_from_cursor_nonexistent() { - // Should not fail when removing from non-existent directory - let result = SkillInstaller::remove_from_cursor("test-skill"); - assert!(result.is_ok()); - } - - #[test] - fn test_remove_from_claude_nonexistent() { - // Should not fail when removing from non-existent directory - let result = SkillInstaller::remove_from_claude("test-skill"); - assert!(result.is_ok()); + assert!(!SkillInstaller::is_installed("nonexistent")); } } diff --git a/src/skills/loader.rs b/src/skills/loader.rs index 0374265..1add3d3 100644 --- a/src/skills/loader.rs +++ b/src/skills/loader.rs @@ -1,8 +1,8 @@ -use crate::config::{self, Config, SkillSource}; +use crate::config::{self, Config, SkillSource, SKILL_FILE}; use crate::skills::{Skill, SkillSource as SkillSourceEnum}; use std::collections::HashMap; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; use walkdir::WalkDir; #[derive(Debug)] @@ -25,32 +25,25 @@ impl SkillLoader { let mut skills = HashMap::new(); let config = Self::load_config(); - // Only use configured sources - no global fallback for source in &config.skill_sources { - let path = PathBuf::from(&source.path); + let path = std::path::PathBuf::from(&source.path); if path.exists() { - Self::load_skills_from_dir(path.as_path(), SkillSourceEnum::Project, &mut skills); + Self::load_from_dir(path.as_path(), &mut skills); } } - skills } fn load_config() -> Config { - let project_config = config::project_config_path(); - if project_config.exists() { - return Config::load(&project_config); + let path = config::global_config_path(); + if path.exists() { + Config::load(&path) + } else { + Config::default() } - - // No project config - return empty default (no global config support) - Config::default() } - fn load_skills_from_dir( - dir: &Path, - source: SkillSourceEnum, - skills: &mut HashMap, - ) { + fn load_from_dir(dir: &Path, skills: &mut HashMap) { if !dir.exists() { return; } @@ -61,14 +54,16 @@ impl SkillLoader { .filter_map(|e| e.ok()) { let path = entry.path(); - if path.is_file() && path.file_name().map(|n| n == "SKILL.md").unwrap_or(false) { + if path.is_file() && path.file_name().map(|n| n == SKILL_FILE).unwrap_or(false) { if let Some(name) = path.parent().and_then(|p| p.file_name()) { - let skill_name = name.to_str().unwrap_or("").to_string(); - if !skill_name.is_empty() && !skills.contains_key(&skill_name) { - if let Some(skill) = - Skill::from_file(skill_name.clone(), path.to_path_buf(), source.clone()) - { - skills.insert(skill_name, skill); + let skill_name = name.to_str().unwrap_or(""); + if !skill_name.is_empty() && !skills.contains_key(skill_name) { + if let Some(skill) = Skill::from_file( + skill_name.to_string(), + path.to_path_buf(), + SkillSourceEnum::Project, + ) { + skills.insert(skill_name.to_string(), skill); } } } @@ -77,59 +72,51 @@ impl SkillLoader { } pub fn load_installed_skills() -> Vec { - let mut installed = Vec::new(); - let agents_dir = config::agents_skills_dir(); - - if agents_dir.exists() { - if let Ok(entries) = fs::read_dir(&agents_dir) { - for entry in entries.filter_map(|e| e.ok()) { - let path = entry.path(); - if path.is_dir() { - if let Some(name) = path.file_name() { - installed.push(name.to_str().unwrap_or("").to_string()); - } - } - } - } + let dir = config::agents_skills_dir(); + if !dir.exists() { + return vec![]; } - installed + fs::read_dir(dir) + .ok() + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .filter_map(|e| e.file_name().to_str().map(String::from)) + .collect() + }) + .unwrap_or_default() } pub fn validate_skills() -> ValidationResult { let skills = Self::load_available_skills(); - let mut errors = Vec::new(); - let mut warnings = Vec::new(); if skills.is_empty() { - errors.push(ValidationError { - skill: "global".to_string(), - message: "No skills found in any configured source".to_string(), - }); return ValidationResult { valid: false, - errors, - warnings, + errors: vec![ValidationError { + skill: "global".to_string(), + message: "No skills found in any configured source".to_string(), + }], + warnings: vec![], }; } - for (name, skill) in &skills { - if skill.content.trim().is_empty() { - errors.push(ValidationError { - skill: name.clone(), - message: "SKILL.md is empty".to_string(), - }); - } - - let lines: Vec<&str> = skill.content.lines().collect(); - if lines.is_empty() || (lines.len() == 1 && lines[0].trim().is_empty()) { - warnings.push(format!("Skill '{}' has minimal content", name)); - } - - if !skill.content.contains("## ") && !skill.content.contains("* ") { - warnings.push(format!("Skill '{}' may not have proper formatting (expected ## headers or bullet points)", name)); - } - } + let errors: Vec<_> = skills + .iter() + .filter(|(_, s)| s.content.trim().is_empty()) + .map(|(name, _)| ValidationError { + skill: name.clone(), + message: "SKILL.md is empty".to_string(), + }) + .collect(); + + let warnings: Vec<_> = skills + .iter() + .filter(|(_, s)| !s.content.contains("## ") && !s.content.contains("* ")) + .map(|(name, _)| format!("Skill '{}' may not have proper formatting", name)) + .collect(); ValidationResult { valid: errors.is_empty(), @@ -139,9 +126,7 @@ impl SkillLoader { } pub fn get_skill_sources() -> Vec { - let config = Self::load_config(); - // Only return configured sources - no global fallback - config.skill_sources + Self::load_config().skill_sources } } @@ -150,95 +135,20 @@ mod tests { use super::*; #[test] - fn test_load_installed_skills_empty() { - let installed = SkillLoader::load_installed_skills(); - // Should be empty when no .agents directory exists - assert!(installed.is_empty()); - } - - #[test] - fn test_validate_skills_structure() { - let result = SkillLoader::validate_skills(); - // If skills exist, they should be valid (no errors) - // If no skills exist, validation should report that - if result.errors.is_empty() { - assert!(result.valid); - } else { - assert!(!result.valid); - } - } - - #[test] - fn test_validation_result_empty_warnings() { - let result = ValidationResult { - valid: true, - errors: vec![], - warnings: vec![], - }; - assert!(result.valid); - assert!(result.errors.is_empty()); - assert!(result.warnings.is_empty()); + fn test_load_installed_empty() { + assert!(SkillLoader::load_installed_skills().is_empty()); } #[test] - fn test_validation_error_creation() { - let error = ValidationError { - skill: "test".to_string(), - message: "test error".to_string(), - }; - assert_eq!(error.skill, "test"); - assert_eq!(error.message, "test error"); - } - - #[test] - fn test_get_skill_sources_empty() { - let sources = SkillLoader::get_skill_sources(); - // Should return default sources when no config exists - assert!(sources.is_empty() || !sources.is_empty()); - } - - #[test] - fn test_load_skills_from_dir_nonexistent() { - let mut skills: HashMap = HashMap::new(); - SkillLoader::load_skills_from_dir( - Path::new("/nonexistent/path"), - SkillSourceEnum::Project, - &mut skills, - ); + fn test_load_from_dir_nonexistent() { + let mut skills = HashMap::new(); + SkillLoader::load_from_dir(Path::new("/nonexistent"), &mut skills); assert!(skills.is_empty()); } #[test] - fn test_load_config_no_config() { - let config = SkillLoader::load_config(); - // Should return default config when no config files exist - assert!(config.skill_sources.is_empty()); - } - - #[test] - fn test_validate_skills_with_warnings() { - // This test validates that warnings are generated for skills with issues - // Since we can't easily mock skills, we test the ValidationResult structure - let result = ValidationResult { - valid: true, - errors: vec![], - warnings: vec!["warning1".to_string(), "warning2".to_string()], - }; - assert!(result.valid); - assert_eq!(result.warnings.len(), 2); - } - - #[test] - fn test_validate_skills_with_errors() { - let result = ValidationResult { - valid: false, - errors: vec![ValidationError { - skill: "test".to_string(), - message: "error".to_string(), - }], - warnings: vec![], - }; - assert!(!result.valid); - assert_eq!(result.errors.len(), 1); + fn test_validation_structure() { + let result = SkillLoader::validate_skills(); + assert_eq!(result.errors.is_empty(), result.valid); } } diff --git a/src/skills/mod.rs b/src/skills/mod.rs index e14a5a8..9922504 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -17,28 +17,10 @@ pub enum SkillSource { Project, } -#[derive(Debug, Deserialize)] -struct SkillFrontmatter { - name: Option, - description: Option, -} - impl Skill { pub fn from_file(name: String, path: PathBuf, source: SkillSource) -> Option { let content = fs::read_to_string(&path).ok()?; - let (description, has_valid_frontmatter) = Self::extract_description(&content, &name); - - // If valid frontmatter exists, validate name matches directory name - if has_valid_frontmatter { - if let Some(ref frontmatter) = Self::parse_frontmatter(&content) { - if let Some(ref fm_name) = frontmatter.name { - if fm_name != &name { - eprintln!("Warning: Skill '{}' has frontmatter name '{}' which doesn't match directory name", - name, fm_name); - } - } - } - } + let description = Self::extract_description(&content); Some(Skill { name, @@ -48,56 +30,25 @@ impl Skill { }) } - fn parse_frontmatter(content: &str) -> Option { - if !content.starts_with("---") { - return None; - } - - let end_marker = content.find("\n---")?; - let frontmatter_yaml = &content[4..end_marker]; // Skip "---\n" - - serde_yaml::from_str(frontmatter_yaml).ok() - } - - fn extract_description(content: &str, skill_name: &str) -> (String, bool) { - // Try to parse frontmatter using YAML - if let Some(frontmatter) = Self::parse_frontmatter(content) { - let has_name = frontmatter.name.is_some(); - let has_desc = frontmatter.description.is_some(); - - if has_name && has_desc { - // Both present, use frontmatter description - return (frontmatter.description.unwrap(), true); - } else if has_name || has_desc { - // Partial frontmatter - warn and fall back - let missing = if !has_name { "name" } else { "description" }; - eprintln!("Warning: Skill '{}' has incomplete frontmatter (missing '{}'), using auto-generated values", - skill_name, missing); + fn extract_description(content: &str) -> String { + // Try to parse YAML frontmatter + if content.starts_with("---") { + if let Some(end) = content.find("\n---") { + let yaml = &content[3..end]; + if let Ok(frontmatter) = serde_yaml::from_str::(yaml) { + if let Some(desc) = frontmatter.get("description").and_then(|v| v.as_str()) { + return desc.to_string(); + } + } } } - // Fallback to extracting from content body (skip frontmatter if present) - let content_without_frontmatter = if content.starts_with("---") { - if let Some(end_marker) = content.find("\n---") { - &content[end_marker + 4..] // Skip past frontmatter - } else { - content - } - } else { - content - }; - - let desc = content_without_frontmatter + // Fallback: extract first non-empty, non-header line + content .lines() - .filter(|line| { - let trimmed = line.trim(); - !trimmed.is_empty() && !trimmed.starts_with("#") && !trimmed.starts_with("---") - }) - .take(1) - .map(|line| line.trim().trim_start_matches("* ").to_string()) - .next() - .unwrap_or_default(); - (desc, false) + .find(|l| !l.trim().is_empty() && !l.trim().starts_with('#')) + .map(|l| l.trim().trim_start_matches("* ").to_string()) + .unwrap_or_default() } } @@ -110,144 +61,54 @@ pub use loader::{SkillLoader, ValidationError, ValidationResult}; #[cfg(test)] mod tests { use super::*; + use crate::config::SKILL_FILE; use tempfile::TempDir; #[test] fn test_extract_description_simple() { let content = "## Planning\n\n* Test first\n* Document everything"; - let (desc, has_fm) = Skill::extract_description(content, "test"); - assert_eq!(desc, "Test first"); - assert!(!has_fm); - } - - #[test] - fn test_extract_description_with_header() { - let content = "# Title\n\n* First bullet\n* Second bullet"; - let (desc, has_fm) = Skill::extract_description(content, "test"); - assert_eq!(desc, "First bullet"); - assert!(!has_fm); - } - - #[test] - fn test_extract_description_empty() { - let content = ""; - let (desc, has_fm) = Skill::extract_description(content, "test"); - assert_eq!(desc, ""); - assert!(!has_fm); + assert_eq!(Skill::extract_description(content), "Test first"); } #[test] fn test_extract_description_from_frontmatter() { let content = "---\nname: planning\ndescription: Frontmatter description\n---\n\n## Planning"; - let (desc, has_fm) = Skill::extract_description(content, "planning"); - assert_eq!(desc, "Frontmatter description"); - assert!(has_fm); - } - - #[test] - fn test_extract_description_incomplete_frontmatter_missing_name() { - let content = "---\ndescription: Only description\n---\n\n## Test"; - let (_desc, has_fm) = Skill::extract_description(content, "test"); - // Should fall back to auto-extraction since name is missing - assert!(!has_fm); + assert_eq!( + Skill::extract_description(content), + "Frontmatter description" + ); } #[test] - fn test_extract_description_incomplete_frontmatter_missing_description() { - let content = "---\nname: test\n---\n\n## Test"; - let (_desc, has_fm) = Skill::extract_description(content, "test"); - // Should fall back to auto-extraction since description is missing - assert!(!has_fm); + fn test_extract_description_empty() { + assert_eq!(Skill::extract_description(""), ""); } #[test] fn test_skill_source_enum_equality() { assert_eq!(SkillSource::Bundled, SkillSource::Bundled); assert_ne!(SkillSource::Bundled, SkillSource::Global); - assert_ne!(SkillSource::Global, SkillSource::Project); - assert_ne!(SkillSource::Bundled, SkillSource::Project); } #[test] fn test_skill_from_file_valid() { let temp_dir = TempDir::new().unwrap(); - let skill_file = temp_dir.path().join("SKILL.md"); + let skill_file = temp_dir.path().join(SKILL_FILE); fs::write(&skill_file, "## Test\n\n* Description").unwrap(); - let skill = Skill::from_file("test".to_string(), skill_file, SkillSource::Project); - - assert!(skill.is_some()); - let skill = skill.unwrap(); + let skill = Skill::from_file("test".to_string(), skill_file, SkillSource::Project).unwrap(); assert_eq!(skill.name, "test"); assert_eq!(skill.description, "Description"); - assert!(skill.content.contains("## Test")); - assert_eq!(skill.source, SkillSource::Project); } #[test] fn test_skill_from_file_nonexistent() { - let skill = Skill::from_file( + assert!(Skill::from_file( "test".to_string(), - PathBuf::from("/nonexistent/path/SKILL.md"), + PathBuf::from("/nonexistent/SKILL.md"), SkillSource::Project, - ); - assert!(skill.is_none()); - } - - #[test] - fn test_extract_description_no_bullets() { - let content = "## Title\n\nJust text without bullets"; - let (desc, has_fm) = Skill::extract_description(content, "test"); - // Should return the first non-empty, non-header line - assert_eq!(desc, "Just text without bullets"); - assert!(!has_fm); - } - - #[test] - fn test_extract_description_only_header() { - let content = "## Title"; - let (desc, has_fm) = Skill::extract_description(content, "test"); - assert_eq!(desc, ""); - assert!(!has_fm); - } - - #[test] - fn test_extract_description_whitespace_only() { - let content = " \n\n "; - let (desc, has_fm) = Skill::extract_description(content, "test"); - assert_eq!(desc, ""); - assert!(!has_fm); - } - - #[test] - fn test_skill_struct_creation() { - let skill = Skill { - name: "test".to_string(), - description: "desc".to_string(), - content: "content".to_string(), - source: SkillSource::Global, - }; - assert_eq!(skill.name, "test"); - assert_eq!(skill.description, "desc"); - assert_eq!(skill.content, "content"); - assert_eq!(skill.source, SkillSource::Global); - } - - #[test] - fn test_yaml_frontmatter_with_special_chars() { - let content = "---\nname: test-skill\ndescription: \"Description with: special chars\"\n---\n\n## Test"; - let (desc, has_fm) = Skill::extract_description(content, "test-skill"); - assert_eq!(desc, "Description with: special chars"); - assert!(has_fm); - } - - #[test] - fn test_yaml_frontmatter_multiline() { - let content = - "---\nname: test-skill\ndescription: |\n Multi-line\n description\n---\n\n## Test"; - let (desc, has_fm) = Skill::extract_description(content, "test-skill"); - assert!(desc.contains("Multi-line")); - assert!(has_fm); + ) + .is_none()); } }