diff --git a/.github/workflows/auto-deploy-on-merge.yml b/.github/workflows/auto-deploy-on-merge.yml new file mode 100644 index 00000000..92243a5a --- /dev/null +++ b/.github/workflows/auto-deploy-on-merge.yml @@ -0,0 +1,20 @@ +name: Auto Deploy on Merge + +on: + pull_request: + types: + - closed + branches: + - main + +jobs: + trigger-deploy: + name: Trigger Deployment on Merge + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Trigger Release + run: | + echo "Pull request merged to main - triggering deployment..." + gh workflow run release.yml -f version=latest diff --git a/.gitignore b/.gitignore index 5eba9364..30ffddfa 100644 --- a/.gitignore +++ b/.gitignore @@ -320,4 +320,5 @@ HEAD ORIG_HEAD commondir gitdir -index \ No newline at end of file +index +.newton \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 7023e512..b55daf21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1292,7 +1292,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fastskill" -version = "0.9.11" +version = "0.9.12" dependencies = [ "anyhow", "async-trait", diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh new file mode 100755 index 00000000..a10cd14d --- /dev/null +++ b/scripts/run-tests.sh @@ -0,0 +1,358 @@ +#!/bin/bash + +# Newton Test Runner Script +# Runs tests with cargo-nextest, captures results, and generates statistics +# +# Usage: ./run-tests.sh [OPTIONS] +# +# Options: +# -o, --output FILE Output markdown report file (default: test_results.md) +# -j, --json FILE JSON results file (default: test_results.json) +# -h, --help Show this help message + +set -e # Exit on any error + +# Default values +OUTPUT_FILE="test_results.md" +JSON_FILE="test_results.json" +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print error and exit +error_exit() { + echo -e "${RED}Error: $1${NC}" >&2 + echo "Usage: $0 [OPTIONS]" >&2 + echo "" >&2 + echo "Options:" >&2 + echo " -o, --output FILE Output markdown report file (default: test_results.md)" >&2 + echo " -j, --json FILE JSON results file (default: test_results.json)" >&2 + echo " -h, --help Show this help message" >&2 + exit 1 +} + +# Function to check if command exists +check_command() { + local cmd=$1 + local description=$2 + if ! command -v "$cmd" >/dev/null 2>&1; then + error_exit "$description ($cmd) is not installed or not in PATH. Please install it first." + fi +} + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + -o|--output) + OUTPUT_FILE="$2" + shift 2 + ;; + -j|--json) + JSON_FILE="$2" + shift 2 + ;; + -h|--help) + echo "Newton Test Runner Script" + echo "" + echo "Runs tests with cargo-nextest, captures results, and generates statistics" + echo "" + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " -o, --output FILE Output markdown report file (default: test_results.md)" + echo " -j, --json FILE JSON results file (default: test_results.json)" + echo " -h, --help Show this help message" + echo "" + echo "Requirements:" + echo " - cargo-nextest: Fast test runner for Rust" + echo "" + echo "Install requirements:" + echo " cargo install cargo-nextest" + exit 0 + ;; + *) + error_exit "Unknown option: $1" + ;; + esac +done + +# Check dependencies +echo -e "${YELLOW}Checking dependencies...${NC}" >&2 + +check_command "cargo" "Cargo (Rust package manager)" +check_command "cargo-nextest" "cargo-nextest (install with: cargo install cargo-nextest)" + +echo -e "${GREEN}All dependencies found!${NC}" >&2 +echo "" >&2 + +# Change to the newton directory (assuming script is run from there) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NEWTON_DIR="$(dirname "$SCRIPT_DIR")" + +echo -e "${YELLOW}Running tests in: $NEWTON_DIR${NC}" >&2 +cd "$NEWTON_DIR" + +# Run tests and capture output +echo -e "${YELLOW}Running tests with cargo-nextest...${NC}" >&2 +if TEST_OUTPUT=$(cargo nextest run --all-features 2>&1); then + OVERALL_STATUS="PASSED" + EXIT_CODE=0 + echo -e "${GREEN}Tests completed successfully!${NC}" >&2 +else + OVERALL_STATUS="FAILED" + EXIT_CODE=1 + echo -e "${RED}Some tests failed!${NC}" >&2 +fi + +echo "" >&2 + +# Parse test results from output +echo -e "${YELLOW}Parsing test results...${NC}" >&2 + +# Check if there are compilation errors (no tests were run) +if echo "$TEST_OUTPUT" | grep -q "error\[" || echo "$TEST_OUTPUT" | grep -q "could not compile"; then + echo -e "${YELLOW}Compilation errors detected - no tests could run${NC}" >&2 + PASSED=0 + FAILED=0 + SKIPPED=0 + TOTAL=0 + PASSING_PERCENTAGE=0 + STATS_AVAILABLE=false + COMPILATION_FAILED=true +else + COMPILATION_FAILED=false + + # Look for summary line like: "Summary [ 0.039s] 20 tests run: 20 passed, 0 failed, 0 skipped" + SUMMARY_LINE=$(echo "$TEST_OUTPUT" | grep "Summary.*tests run:" | head -1) + + if [ -n "$SUMMARY_LINE" ]; then + # Extract numbers from summary line + PASSED=$(echo "$SUMMARY_LINE" | sed -n 's/.* \([0-9]*\) passed.*/\1/p') + FAILED=$(echo "$SUMMARY_LINE" | sed -n 's/.* \([0-9]*\) failed.*/\1/p') + SKIPPED=$(echo "$SUMMARY_LINE" | sed -n 's/.* \([0-9]*\) skipped.*/\1/p') + + # If parsing failed, try alternative format + if [ -z "$PASSED" ]; then + # Try format: "Summary [ 0.039s] 20 tests run: 20 passed (0 slow), 0 failed, 0 skipped" + PASSED=$(echo "$SUMMARY_LINE" | sed -n 's/.*: \([0-9]*\) passed.*/\1/p') + FAILED=$(echo "$SUMMARY_LINE" | sed -n 's/.* \([0-9]*\) failed.*/\1/p') + SKIPPED=$(echo "$SUMMARY_LINE" | sed -n 's/.* \([0-9]*\) skipped.*/\1/p') + fi + + # Calculate total and percentage + TOTAL=$((PASSED + FAILED + SKIPPED)) + + if [ "$TOTAL" -gt 0 ]; then + PASSING_PERCENTAGE=$((PASSED * 100 / TOTAL)) + else + PASSING_PERCENTAGE=0 + fi + + STATS_AVAILABLE=true + else + # Fallback: try to parse from individual test results + PASSED_COUNT=$(echo "$TEST_OUTPUT" | grep -c "PASS\|✓") + FAILED_COUNT=$(echo "$TEST_OUTPUT" | grep -c "FAIL\|✗") + SKIPPED_COUNT=$(echo "$TEST_OUTPUT" | grep -c "SKIP") + + PASSED=${PASSED_COUNT:-0} + FAILED=${FAILED_COUNT:-0} + SKIPPED=${SKIPPED_COUNT:-0} + TOTAL=$((PASSED + FAILED + SKIPPED)) + + if [ "$TOTAL" -gt 0 ]; then + PASSING_PERCENTAGE=$((PASSED * 100 / TOTAL)) + else + PASSING_PERCENTAGE=0 + fi + + STATS_AVAILABLE=true + fi +fi + +# Get failed test names (if any) +FAILED_TESTS="" +if [ "$FAILED" -gt 0 ]; then + # Extract failed test names from output + FAILED_TESTS=$(echo "$TEST_OUTPUT" | grep -A 5 -B 1 "FAIL\|✗" | grep "^\s*[^-]*test.*" | sed 's/.*--- \(.*\) ---.*/\1/' | grep -v "^\s*$" | head -10) +fi + +# Create structured JSON output +echo -e "${YELLOW}Generating JSON output...${NC}" >&2 +if [ "$COMPILATION_FAILED" = true ]; then + # Create JSON for compilation errors + cat > "$JSON_FILE" << EOF +{ + "status": "compilation_failed", + "timestamp": "$TIMESTAMP", + "command": "$0", + "exit_code": $EXIT_CODE, + "test_statistics": { + "total": 0, + "passed": 0, + "failed": 0, + "skipped": 0, + "passing_percentage": 0 + } +} +EOF +else + # Create JSON for successful test runs + cat > "$JSON_FILE" << EOF +{ + "status": "completed", + "timestamp": "$TIMESTAMP", + "command": "$0", + "exit_code": $EXIT_CODE, + "test_statistics": { + "total": $TOTAL, + "passed": ${PASSED:-0}, + "failed": ${FAILED:-0}, + "skipped": ${SKIPPED:-0}, + "passing_percentage": $PASSING_PERCENTAGE + } +} +EOF +fi + +# Generate comprehensive report +echo -e "${YELLOW}Generating report: $OUTPUT_FILE${NC}" >&2 + +{ + echo "# Newton Test Results Report" + echo "Generated: $TIMESTAMP" + echo "Command: $0" + echo "Output File: $OUTPUT_FILE" + echo "JSON File: $JSON_FILE" + echo "" + + echo "## Overall Status" + if [ "$COMPILATION_FAILED" = true ]; then + echo "❌ **COMPILATION FAILED** - Code does not compile, tests cannot run" + elif [ "$EXIT_CODE" -eq 0 ]; then + echo "✅ **PASSED** - All tests completed successfully" + else + echo "❌ **FAILED** - Some tests failed" + fi + echo "" + + echo "## Test Statistics" + if [ "$COMPILATION_FAILED" = true ]; then + echo "- **Status:** Compilation failed - no tests executed" + echo "- **Total Tests:** N/A" + echo "- **Passed:** N/A" + echo "- **Failed:** N/A" + echo "- **Skipped:** N/A" + echo "- **Passing Rate:** N/A" + else + echo "- **Total Tests:** $TOTAL" + echo "- **Passed:** $PASSED" + echo "- **Failed:** $FAILED" + echo "- **Skipped:** $SKIPPED" + echo "- **Passing Rate:** ${PASSING_PERCENTAGE}%" + fi + echo "" + + # Progress bar visualization + if [ "$COMPILATION_FAILED" = true ]; then + echo "## Progress Visualization" + echo "\`\`\`" + echo "[░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] COMPILATION FAILED" + echo "\`\`\`" + echo "" + elif [ "$TOTAL" -gt 0 ]; then + echo "## Progress Visualization" + BAR_WIDTH=30 + FILLED=$((PASSED * BAR_WIDTH / TOTAL)) + EMPTY=$((BAR_WIDTH - FILLED)) + + echo "\`\`\`" + printf "[" + for ((i=0; i "$OUTPUT_FILE" + +# Console output summary +echo -e "${GREEN}Report generated successfully!${NC}" >&2 +echo "" >&2 + +if [ "$COMPILATION_FAILED" = true ]; then + echo "📊 Test Summary:" >&2 + echo " Status: COMPILATION FAILED - no tests executed" >&2 + echo -e "${RED}❌ Code does not compile. Check $OUTPUT_FILE for compilation errors.${NC}" >&2 +else + echo "📊 Test Summary:" >&2 + echo " Total: $TOTAL tests" >&2 + echo " Passed: $PASSED (${PASSING_PERCENTAGE}%)" >&2 + echo " Failed: $FAILED" >&2 + echo " Skipped: $SKIPPED" >&2 + echo "" >&2 + + if [ "$EXIT_CODE" -eq 0 ]; then + echo -e "${GREEN}✅ All tests passed!${NC}" >&2 + else + echo -e "${RED}❌ Some tests failed. Check $OUTPUT_FILE for details.${NC}" >&2 + fi +fi + +echo "" >&2 +echo "📁 Files created:" >&2 +echo " Markdown report: $OUTPUT_FILE" >&2 +echo " Raw output: $JSON_FILE" >&2 + +exit $EXIT_CODE \ No newline at end of file diff --git a/src/cli/commands/registry.rs b/src/cli/commands/registry.rs deleted file mode 100644 index d4de9ef4..00000000 --- a/src/cli/commands/registry.rs +++ /dev/null @@ -1,1307 +0,0 @@ -//! Unified registry command implementation -//! -//! This command consolidates functionality from sources, registry, and repository commands -//! into a single unified interface for managing repositories and browsing skills. - -use crate::cli::error::{CliError, CliResult}; -use crate::cli::utils::messages; -use clap::{Args, Subcommand}; -use fastskill::core::manifest::MetadataSection; -use fastskill::core::metadata::parse_yaml_frontmatter; -use fastskill::core::repository::{ - RepositoryAuth, RepositoryConfig, RepositoryDefinition, RepositoryManager, RepositoryType, -}; -use fastskill::core::sources::{ - ClaudeCodeMarketplaceJson, ClaudeCodeMetadata, ClaudeCodeOwner, ClaudeCodePlugin, - MarketplaceSkill, -}; -use serde_json; -use std::fs; -use std::path::{Path, PathBuf}; -use toml; -use tracing::{info, warn}; -use walkdir::WalkDir; - -/// Registry management commands -#[derive(Debug, Args)] -pub struct RegistryArgs { - #[command(subcommand)] - pub command: RegistryCommand, -} - -#[derive(Debug, Subcommand)] -pub enum RegistryCommand { - /// List all configured repositories - List, - - /// Add a new repository - Add { - /// Repository name - name: String, - /// Repository type: git-marketplace, http-registry, zip-url, or local - #[arg(long)] - repo_type: String, - /// URL for git-marketplace or http-registry, base_url for zip-url, or path for local - url_or_path: String, - /// Priority (lower number = higher priority, default: 0) - #[arg(long)] - priority: Option, - /// Branch for git-marketplace - #[arg(long)] - branch: Option, - /// Tag for git-marketplace - #[arg(long)] - tag: Option, - /// Authentication type: pat, ssh-key, ssh, basic, or api_key - #[arg(long)] - auth_type: Option, - /// Environment variable for PAT, basic password, or API key - #[arg(long)] - auth_env: Option, - /// SSH key path (for ssh-key or ssh auth) - #[arg(long)] - auth_key_path: Option, - /// Username (for basic auth) - #[arg(long)] - auth_username: Option, - }, - - /// Remove a repository - Remove { - /// Repository name to remove - name: String, - }, - - /// Show repository details - Show { - /// Repository name - name: String, - }, - - /// Update repository metadata - Update { - /// Repository name to update - name: String, - /// New branch (for git-marketplace) - #[arg(long)] - branch: Option, - /// New priority - #[arg(long)] - priority: Option, - }, - - /// Test repository connectivity - Test { - /// Repository name to test - name: String, - }, - - /// Refresh repository cache - Refresh { - /// Repository name to refresh (if not specified, refreshes all) - name: Option, - }, - - /// List all skills in registry - ListSkills { - /// Repository name to list skills from (defaults to default repository if not specified) - #[arg(long)] - repository: Option, - /// Filter by scope (organization name) - #[arg(long)] - scope: Option, - /// Show all versions for each skill - #[arg(long)] - all_versions: bool, - /// Include pre-release versions - #[arg(long)] - include_pre_release: bool, - /// Output in JSON format - #[arg(long)] - json: bool, - /// Output in grid format (default) - #[arg(long)] - grid: bool, - }, - - /// Show skill details - ShowSkill { - /// Skill ID - skill_id: String, - /// Repository name (defaults to default repository if not specified) - #[arg(long)] - repository: Option, - }, - - /// List available versions for a skill - Versions { - /// Skill ID - skill_id: String, - /// Repository name (defaults to default repository if not specified) - #[arg(long)] - repository: Option, - }, - - /// Search skills in registry - Search { - /// Search query - query: String, - /// Repository name to search (searches all if not specified) - #[arg(long)] - repository: Option, - }, - - /// Create marketplace.json from a directory containing skills - Create { - /// Directory containing skills to scan - #[arg(short, long, default_value = ".")] - path: PathBuf, - /// Output file path (default: .claude-plugin/marketplace.json in the specified directory) - #[arg(short, long)] - output: Option, - /// Base URL for download links (optional) - #[arg(long)] - base_url: Option, - /// Repository name (required) - #[arg(long)] - name: Option, - /// Owner name (optional) - #[arg(long)] - owner_name: Option, - /// Owner email (optional) - #[arg(long)] - owner_email: Option, - /// Repository description (optional) - #[arg(long)] - description: Option, - /// Repository version (optional) - #[arg(long)] - version: Option, - }, -} - -pub async fn execute_registry(args: RegistryArgs) -> CliResult<()> { - match args.command { - RegistryCommand::List => execute_list().await, - RegistryCommand::Add { - name, - repo_type, - url_or_path, - priority, - branch, - tag, - auth_type, - auth_env, - auth_key_path, - auth_username, - } => { - execute_add( - name, - repo_type, - url_or_path, - priority, - branch, - tag, - auth_type, - auth_env, - auth_key_path, - auth_username, - ) - .await - } - RegistryCommand::Remove { name } => execute_remove(name).await, - RegistryCommand::Show { name } => execute_show(name).await, - RegistryCommand::Update { - name, - branch, - priority, - } => execute_update(name, branch, priority).await, - RegistryCommand::Test { name } => execute_test(name).await, - RegistryCommand::Refresh { name } => execute_refresh(name).await, - RegistryCommand::ListSkills { - repository, - scope, - all_versions, - include_pre_release, - json, - grid, - } => { - execute_list_skills( - repository, - scope, - all_versions, - include_pre_release, - json, - grid, - ) - .await - } - RegistryCommand::ShowSkill { - skill_id, - repository, - } => execute_show_skill(skill_id, repository).await, - RegistryCommand::Versions { - skill_id, - repository, - } => execute_versions(skill_id, repository).await, - RegistryCommand::Search { query, repository } => execute_search(query, repository).await, - RegistryCommand::Create { - path, - output, - base_url, - name, - owner_name, - owner_email, - description, - version, - } => { - execute_create( - path, - output, - base_url, - name, - owner_name, - owner_email, - description, - version, - ) - .await - } - } -} - -// Repository management functions - -async fn execute_list() -> CliResult<()> { - // T031: Try to load from skill-project.toml first, fall back to repositories.toml - let repos_path = crate::cli::config::get_repositories_toml_path() - .map_err(|e| CliError::Config(format!("Failed to find repositories.toml: {}", e)))?; - let mut repo_manager = RepositoryManager::new(repos_path); - repo_manager - .load() - .map_err(|e| CliError::Config(format!("Failed to load repositories: {}", e)))?; - - let repos = repo_manager.list_repositories(); - if repos.is_empty() { - println!("No repositories configured."); - } else { - println!("Configured Repositories ({}):\n", repos.len()); - for repo in repos { - let repo_type_str = match repo.repo_type { - RepositoryType::GitMarketplace => "git-marketplace", - RepositoryType::HttpRegistry => "http-registry", - RepositoryType::ZipUrl => "zip-url", - RepositoryType::Local => "local", - }; - println!( - " • {} (type: {}, priority: {})", - repo.name, repo_type_str, repo.priority - ); - } - } - Ok(()) -} - -async fn execute_add( - name: String, - repo_type: String, - url_or_path: String, - priority: Option, - branch: Option, - tag: Option, - auth_type: Option, - auth_env: Option, - auth_key_path: Option, - auth_username: Option, -) -> CliResult<()> { - let repos_path = crate::cli::config::get_repositories_toml_path() - .map_err(|e| CliError::Config(format!("Failed to find repositories.toml: {}", e)))?; - let mut repo_manager = RepositoryManager::new(repos_path); - repo_manager - .load() - .map_err(|e| CliError::Config(format!("Failed to load repositories: {}", e)))?; - - // Parse repository type - let repo_type_enum = match repo_type.as_str() { - "git-marketplace" => RepositoryType::GitMarketplace, - "http-registry" => RepositoryType::HttpRegistry, - "zip-url" => RepositoryType::ZipUrl, - "local" => RepositoryType::Local, - _ => { - return Err(CliError::Config(format!( - "Invalid repository type: {}. Use: git-marketplace, http-registry, zip-url, or local", - repo_type - ))) - } - }; - - // Parse repository config - let config = match repo_type_enum { - RepositoryType::GitMarketplace => RepositoryConfig::GitMarketplace { - url: url_or_path, - branch, - tag, - }, - RepositoryType::HttpRegistry => RepositoryConfig::HttpRegistry { - index_url: url_or_path, - }, - RepositoryType::ZipUrl => RepositoryConfig::ZipUrl { - base_url: url_or_path, - }, - RepositoryType::Local => RepositoryConfig::Local { - path: PathBuf::from(url_or_path), - }, - }; - - // Parse authentication - let auth = if let Some(auth_t) = auth_type { - match auth_t.as_str() { - "pat" => { - let env_var = auth_env.ok_or_else(|| { - CliError::Config("--auth-env required for pat authentication".to_string()) - })?; - Some(RepositoryAuth::Pat { env_var }) - } - "ssh-key" | "ssh" => { - let key_path = auth_key_path.ok_or_else(|| { - CliError::Config("--auth-key-path required for ssh authentication".to_string()) - })?; - if auth_t == "ssh-key" { - Some(RepositoryAuth::SshKey { path: key_path }) - } else { - Some(RepositoryAuth::Ssh { key_path }) - } - } - "basic" => { - let username = auth_username.ok_or_else(|| { - CliError::Config( - "--auth-username required for basic authentication".to_string(), - ) - })?; - let password_env = auth_env.ok_or_else(|| { - CliError::Config("--auth-env required for basic authentication".to_string()) - })?; - Some(RepositoryAuth::Basic { - username, - password_env, - }) - } - "api_key" => { - let env_var = auth_env.ok_or_else(|| { - CliError::Config("--auth-env required for api_key authentication".to_string()) - })?; - Some(RepositoryAuth::ApiKey { env_var }) - } - _ => { - return Err(CliError::Config(format!( - "Invalid auth type: {}. Use: pat, ssh-key, ssh, basic, api_key", - auth_t - ))); - } - } - } else { - None - }; - - let repo_def = RepositoryDefinition { - name: name.clone(), - repo_type: repo_type_enum, - priority: priority.unwrap_or(0), - config, - auth, - storage: None, - }; - - repo_manager - .add_repository(name.clone(), repo_def.clone()) - .map_err(|e| CliError::Config(format!("Failed to add repository: {}", e)))?; - repo_manager - .save() - .map_err(|e| CliError::Config(format!("Failed to save repositories: {}", e)))?; - - // T030: Also write to skill-project.toml [tool.fastskill.repositories] - // For now, we keep both files in sync. In the future, we can deprecate repositories.toml - // TODO: Implement add_repository_to_project_toml when auth conversion is complete - - println!("{}", messages::ok(&format!("Added repository: {}", name))); - Ok(()) -} - -async fn execute_remove(name: String) -> CliResult<()> { - let repos_path = crate::cli::config::get_repositories_toml_path() - .map_err(|e| CliError::Config(format!("Failed to find repositories.toml: {}", e)))?; - let mut repo_manager = RepositoryManager::new(repos_path); - repo_manager - .load() - .map_err(|e| CliError::Config(format!("Failed to load repositories: {}", e)))?; - - repo_manager - .remove_repository(&name) - .map_err(|e| CliError::Config(format!("Failed to remove repository: {}", e)))?; - repo_manager - .save() - .map_err(|e| CliError::Config(format!("Failed to save repositories: {}", e)))?; - - println!("{}", messages::ok(&format!("Removed repository: {}", name))); - Ok(()) -} - -async fn execute_show(name: String) -> CliResult<()> { - let repos_path = crate::cli::config::get_repositories_toml_path() - .map_err(|e| CliError::Config(format!("Failed to find repositories.toml: {}", e)))?; - let mut repo_manager = RepositoryManager::new(repos_path); - repo_manager - .load() - .map_err(|e| CliError::Config(format!("Failed to load repositories: {}", e)))?; - - let repo = repo_manager - .get_repository(&name) - .ok_or_else(|| CliError::Config(format!("Repository '{}' not found", name)))?; - - let repo_type_str = match repo.repo_type { - RepositoryType::GitMarketplace => "git-marketplace", - RepositoryType::HttpRegistry => "http-registry", - RepositoryType::ZipUrl => "zip-url", - RepositoryType::Local => "local", - }; - - println!("Repository: {}", repo.name); - println!(" Type: {}", repo_type_str); - println!(" Priority: {}", repo.priority); - - match &repo.config { - RepositoryConfig::GitMarketplace { url, branch, tag } => { - println!(" URL: {}", url); - if let Some(b) = branch { - println!(" Branch: {}", b); - } - if let Some(t) = tag { - println!(" Tag: {}", t); - } - } - RepositoryConfig::HttpRegistry { index_url } => { - println!(" Index URL: {}", index_url); - } - RepositoryConfig::ZipUrl { base_url } => { - println!(" Base URL: {}", base_url); - } - RepositoryConfig::Local { path } => { - println!(" Path: {}", path.display()); - } - } - - if let Some(auth) = &repo.auth { - println!(" Auth: {:?}", auth); - } - - if let Some(storage) = &repo.storage { - println!(" Storage: {:?}", storage); - } - - Ok(()) -} - -async fn execute_update( - name: String, - branch: Option, - priority: Option, -) -> CliResult<()> { - let repos_path = crate::cli::config::get_repositories_toml_path() - .map_err(|e| CliError::Config(format!("Failed to find repositories.toml: {}", e)))?; - let mut repo_manager = RepositoryManager::new(repos_path); - repo_manager - .load() - .map_err(|e| CliError::Config(format!("Failed to load repositories: {}", e)))?; - - let repo = repo_manager - .get_repository(&name) - .ok_or_else(|| CliError::Config(format!("Repository '{}' not found", name)))? - .clone(); - - // Update branch if specified (for git-marketplace) - let updated_config = if let Some(new_branch) = branch { - match &repo.config { - RepositoryConfig::GitMarketplace { - url, - branch: _, - tag, - } => RepositoryConfig::GitMarketplace { - url: url.clone(), - branch: Some(new_branch), - tag: tag.clone(), - }, - _ => repo.config.clone(), - } - } else { - repo.config.clone() - }; - - // Update priority if specified - let updated_priority = priority.unwrap_or(repo.priority); - - // Remove and re-add with updated config - repo_manager - .remove_repository(&name) - .map_err(|e| CliError::Config(format!("Failed to remove repository: {}", e)))?; - - let updated_repo = RepositoryDefinition { - name: repo.name.clone(), - repo_type: repo.repo_type, - priority: updated_priority, - config: updated_config, - auth: repo.auth, - storage: repo.storage, - }; - - repo_manager - .add_repository(name.clone(), updated_repo) - .map_err(|e| CliError::Config(format!("Failed to add repository: {}", e)))?; - repo_manager - .save() - .map_err(|e| CliError::Config(format!("Failed to save repositories: {}", e)))?; - - println!("{}", messages::ok(&format!("Updated repository: {}", name))); - Ok(()) -} - -async fn execute_test(name: String) -> CliResult<()> { - let repos_path = crate::cli::config::get_repositories_toml_path() - .map_err(|e| CliError::Config(format!("Failed to find repositories.toml: {}", e)))?; - let mut repo_manager = RepositoryManager::new(repos_path); - repo_manager - .load() - .map_err(|e| CliError::Config(format!("Failed to load repositories: {}", e)))?; - - let _repo = repo_manager - .get_repository(&name) - .ok_or_else(|| CliError::Config(format!("Repository '{}' not found", name)))?; - - println!( - "{}", - messages::info(&format!("Testing repository: {}...", name)) - ); - - // Try to get client and list skills - match repo_manager.get_client(&name).await { - Ok(client) => match client.list_skills().await { - Ok(skills) => { - println!( - "{}", - messages::ok(&format!( - "Repository '{}' is accessible ({} skills found)", - name, - skills.len() - )) - ); - } - Err(e) => { - return Err(CliError::Config(format!( - "Repository '{}' test failed: {}", - name, e - ))); - } - }, - Err(e) => { - return Err(CliError::Config(format!( - "Repository '{}' test failed: {}", - name, e - ))); - } - } - - Ok(()) -} - -async fn execute_refresh(name: Option) -> CliResult<()> { - // Note: Cache clearing would need to be implemented in RepositoryManager - // For now, we'll just acknowledge the command - if let Some(repo_name) = name { - println!( - "{}", - messages::ok(&format!("Refreshed cache for repository: {}", repo_name)) - ); - } else { - println!("{}", messages::ok("Refreshed cache for all repositories")); - } - Ok(()) -} - -// Skill browsing functions - -async fn execute_list_skills( - repository: Option, - scope: Option, - all_versions: bool, - include_pre_release: bool, - json: bool, - grid: bool, -) -> CliResult<()> { - use fastskill::core::registry_index::ListSkillsOptions; - use fastskill::core::repository::{CratesRegistryClient, RepositoryType}; - - // Validate conflicting flags - if json && grid { - return Err(CliError::Config( - "Cannot use both --json and --grid flags. Use only one.".to_string(), - )); - } - - // Validate scope format (filesystem-safe characters only) - if let Some(ref scope) = scope { - if scope.is_empty() { - return Err(CliError::Config( - "Scope cannot be empty. Use a valid organization name.".to_string(), - )); - } - // Scope must not contain path separators or other unsafe characters - if scope.contains('/') || scope.contains('\\') || scope.contains("..") { - return Err(CliError::Config( - format!( - "Invalid scope format: '{}'. Scope must be a valid organization name without path separators.", - scope - ) - )); - } - // Scope should be filesystem-safe (alphanumeric, hyphens, underscores) - if !scope - .chars() - .all(|c| c.is_alphanumeric() || c == '-' || c == '_') - { - return Err(CliError::Config( - format!( - "Invalid scope format: '{}'. Scope must contain only alphanumeric characters, hyphens, and underscores.", - scope - ) - )); - } - } - - let repos_path = crate::cli::config::get_repositories_toml_path() - .map_err(|e| CliError::Config(format!("Failed to find repositories.toml: {}", e)))?; - let mut repo_manager = RepositoryManager::new(repos_path); - repo_manager - .load() - .map_err(|e| CliError::Config(format!("Failed to load repositories: {}", e)))?; - - let repo_name = if let Some(repo_name) = repository { - repo_name - } else { - repo_manager - .get_default_repository() - .map(|r| r.name.clone()) - .ok_or_else(|| { - CliError::Config( - "No repository specified and no default repository configured".to_string(), - ) - })? - }; - - // Get repository definition - let repo_def = repo_manager - .get_repository(&repo_name) - .ok_or_else(|| CliError::Config(format!("Repository '{}' not found", repo_name)))?; - - // Check if it's an HTTP registry - if repo_def.repo_type != RepositoryType::HttpRegistry { - return Err(CliError::Config( - format!( - "Repository '{}' is not an HTTP registry. This command only works with HTTP registries.", - repo_name - ) - )); - } - - // Check if index_url is configured - let _index_url = match &repo_def.config { - fastskill::core::repository::RepositoryConfig::HttpRegistry { index_url } => { - index_url.clone() - } - _ => { - return Err(CliError::Config( - "Repository does not have index_url configured".to_string(), - )); - } - }; - - println!( - "{}", - messages::info(&format!("Listing skills from repository: {}", repo_name)) - ); - - // Create HTTP registry client - let http_client = CratesRegistryClient::new(repo_def) - .map_err(|e| CliError::Config(format!("Failed to create HTTP registry client: {}", e)))?; - - // Build options - let options = ListSkillsOptions { - scope, - all_versions, - include_pre_release, - }; - - // Fetch skills from HTTP endpoint - let summaries = http_client - .fetch_skills(&options) - .await - .map_err(|e| CliError::Config(format!("Failed to fetch skills from registry: {}", e)))?; - - if summaries.is_empty() { - println!("{}", messages::warning("No skills found in repository")); - return Ok(()); - } - - // Format output - let output_format = if json { "json" } else { "grid" }; - match output_format { - "json" => { - let json_output = serde_json::to_string_pretty(&summaries) - .map_err(|e| CliError::Config(format!("Failed to serialize JSON: {}", e)))?; - println!("{}", json_output); - } - "grid" => { - format_grid_output(&summaries, all_versions)?; - } - _ => unreachable!(), - } - - Ok(()) -} - -/// Format skill summaries as a grid table -fn format_grid_output( - summaries: &[fastskill::core::registry_index::SkillSummary], - all_versions: bool, -) -> CliResult<()> { - // When all_versions is true, each summary represents a specific version - // The grid should show "Version" instead of "Latest Version" - // Simple table formatting without external dependencies - let headers = if all_versions { - vec!["Scope", "Name", "Description", "Version", "Published"] - } else { - vec![ - "Scope", - "Name", - "Description", - "Latest Version", - "Published", - ] - }; - - // Calculate column widths - let mut col_widths = vec![0; headers.len()]; - for (i, header) in headers.iter().enumerate() { - col_widths[i] = header.len(); - } - - for summary in summaries { - col_widths[0] = col_widths[0].max(summary.scope.len()); - col_widths[1] = col_widths[1].max(summary.name.len()); - let desc_len = summary.description.len().min(50); - col_widths[2] = col_widths[2].max(desc_len); - col_widths[3] = col_widths[3].max(summary.latest_version.len()); - col_widths[4] = col_widths[4].max(10); // Date format "YYYY-MM-DD" - } - - // Print header - let header_row: Vec = headers - .iter() - .enumerate() - .map(|(i, h)| format!("{:width$}", h, width = col_widths[i])) - .collect(); - println!("\n{}", header_row.join(" ")); - println!("{}", "-".repeat(header_row.join(" ").len())); - - // Print rows - for summary in summaries { - // Truncate description to 50 characters - let description = if summary.description.len() > 50 { - format!("{}...", &summary.description[..47]) - } else { - summary.description.clone() - }; - - // Format published date - let published = summary - .published_at - .map(|dt| dt.format("%Y-%m-%d").to_string()) - .unwrap_or_else(|| "N/A".to_string()); - - let row = [ - format!("{:width$}", summary.scope, width = col_widths[0]), - format!("{:width$}", summary.name, width = col_widths[1]), - format!("{:width$}", description, width = col_widths[2]), - format!("{:width$}", summary.latest_version, width = col_widths[3]), - format!("{:width$}", published, width = col_widths[4]), - ]; - println!("{}", row.join(" ")); - } - - println!(); - Ok(()) -} - -async fn execute_show_skill(skill_id: String, repository: Option) -> CliResult<()> { - let repos_path = crate::cli::config::get_repositories_toml_path() - .map_err(|e| CliError::Config(format!("Failed to find repositories.toml: {}", e)))?; - let mut repo_manager = RepositoryManager::new(repos_path); - repo_manager - .load() - .map_err(|e| CliError::Config(format!("Failed to load repositories: {}", e)))?; - - let repo_name = if let Some(repo_name) = repository { - repo_name - } else { - repo_manager - .get_default_repository() - .map(|r| r.name.clone()) - .ok_or_else(|| { - CliError::Config( - "No repository specified and no default repository configured".to_string(), - ) - })? - }; - - println!( - "{}", - messages::info(&format!("Fetching skill: {} from {}", skill_id, repo_name)) - ); - - let client = repo_manager - .get_client(&repo_name) - .await - .map_err(|e| CliError::Config(format!("Failed to get repository client: {}", e)))?; - - match client.get_skill(&skill_id, None).await { - Ok(Some(skill)) => { - println!("\nSkill: {}", skill.name); - println!("Version: {}", skill.version); - if !skill.description.is_empty() { - println!("Description: {}", skill.description); - } - if let Some(author) = &skill.author { - println!("Author: {}", author); - } - } - Ok(None) => { - println!( - "{}", - messages::warning(&format!("Skill '{}' not found in repository", skill_id)) - ); - } - Err(e) => { - return Err(CliError::Config(format!("Failed to get skill: {}", e))); - } - } - - Ok(()) -} - -async fn execute_versions(skill_id: String, repository: Option) -> CliResult<()> { - let repos_path = crate::cli::config::get_repositories_toml_path() - .map_err(|e| CliError::Config(format!("Failed to find repositories.toml: {}", e)))?; - let mut repo_manager = RepositoryManager::new(repos_path); - repo_manager - .load() - .map_err(|e| CliError::Config(format!("Failed to load repositories: {}", e)))?; - - let repo_name = if let Some(repo_name) = repository { - repo_name - } else { - repo_manager - .get_default_repository() - .map(|r| r.name.clone()) - .ok_or_else(|| { - CliError::Config( - "No repository specified and no default repository configured".to_string(), - ) - })? - }; - - println!( - "{}", - messages::info(&format!( - "Fetching versions for: {} from {}", - skill_id, repo_name - )) - ); - - let client = repo_manager - .get_client(&repo_name) - .await - .map_err(|e| CliError::Config(format!("Failed to get repository client: {}", e)))?; - - match client.get_versions(&skill_id).await { - Ok(versions) => { - if versions.is_empty() { - println!( - "{}", - messages::warning(&format!("No versions found for skill: {}", skill_id)) - ); - return Ok(()); - } - - println!("\nAvailable versions:"); - for version in versions { - println!(" - {}", version); - } - Ok(()) - } - Err(e) => Err(CliError::Config(format!("Failed to get versions: {}", e))), - } -} - -async fn execute_search(query: String, repository: Option) -> CliResult<()> { - let repos_path = crate::cli::config::get_repositories_toml_path() - .map_err(|e| CliError::Config(format!("Failed to find repositories.toml: {}", e)))?; - let mut repo_manager = RepositoryManager::new(repos_path); - repo_manager - .load() - .map_err(|e| CliError::Config(format!("Failed to load repositories: {}", e)))?; - - println!( - "{}", - messages::info(&format!("Searching repositories for: {}", query)) - ); - - let repos = if let Some(repo_name) = repository { - vec![repo_manager - .get_repository(&repo_name) - .ok_or_else(|| CliError::Config(format!("Repository '{}' not found", repo_name)))?] - } else { - repo_manager.list_repositories() - }; - - let mut all_results = Vec::new(); - - for repo in repos { - match repo_manager.get_client(&repo.name).await { - Ok(client) => { - if let Ok(results) = client.search(&query).await { - all_results.extend(results); - } - } - Err(_) => continue, - } - } - - if all_results.is_empty() { - println!("{}", messages::warning("No results found")); - } else { - println!("\nFound {} result(s):", all_results.len()); - for result in all_results { - println!(" - {}: {}", result.name, result.description); - } - } - - Ok(()) -} - -// Marketplace creation function - -async fn execute_create( - path: PathBuf, - output: Option, - _base_url: Option, - name: Option, - owner_name: Option, - owner_email: Option, - description: Option, - version: Option, -) -> CliResult<()> { - let skill_dir = path - .canonicalize() - .map_err(|e| CliError::Validation(format!("Failed to resolve path: {}", e)))?; - - info!("Scanning directory for skills: {}", skill_dir.display()); - - // Scan for SKILL.md files - let skills = scan_directory_for_skills(&skill_dir)?; - - if skills.is_empty() { - return Err(CliError::Validation(format!( - "No skills found in directory: {}", - skill_dir.display() - ))); - } - - let skills_count = skills.len(); - info!("Found {} skills", skills_count); - - // Determine output path (default to .claude-plugin/marketplace.json) - let output_path = - output.unwrap_or_else(|| skill_dir.join(".claude-plugin").join("marketplace.json")); - - // Validate required fields - let repo_name = name - .or_else(|| { - skill_dir - .file_name() - .and_then(|n| n.to_str().map(|s| s.to_string())) - }) - .ok_or_else(|| { - CliError::Validation( - "Repository name is required. Use --name or ensure directory has a name." - .to_string(), - ) - })?; - - // Group skills into a single plugin (simple approach) - let skill_paths: Vec = skills - .iter() - .map(|skill| format!("./{}", skill.id)) - .collect(); - - let plugin = ClaudeCodePlugin { - name: repo_name.clone(), - description: description.clone(), - source: Some("./".to_string()), - strict: Some(false), - skills: skill_paths, - }; - - let marketplace = ClaudeCodeMarketplaceJson { - name: repo_name, - owner: if owner_name.is_some() || owner_email.is_some() { - Some(ClaudeCodeOwner { - name: owner_name.unwrap_or_else(|| "Unknown".to_string()), - email: owner_email, - }) - } else { - None - }, - metadata: if description.is_some() || version.is_some() { - Some(ClaudeCodeMetadata { - description, - version, - }) - } else { - None - }, - plugins: vec![plugin], - }; - - // Generate Claude Code format marketplace.json - let json_content = serde_json::to_string_pretty(&marketplace).map_err(|e| { - CliError::Validation(format!("Failed to serialize marketplace.json: {}", e)) - })?; - - // Create parent directory if needed (for .claude-plugin/marketplace.json) - if let Some(parent) = output_path.parent() { - fs::create_dir_all(parent).map_err(|e| { - CliError::Validation(format!("Failed to create output directory: {}", e)) - })?; - } - - fs::write(&output_path, json_content) - .map_err(|e| CliError::Validation(format!("Failed to write marketplace.json: {}", e)))?; - - println!( - "{}", - messages::ok(&format!( - "Created marketplace.json: {}", - output_path.display() - )) - ); - println!(" Found {} skills", skills_count); - - Ok(()) -} - -fn scan_directory_for_skills(dir: &Path) -> CliResult> { - let mut skills = Vec::new(); - - for entry in WalkDir::new(dir) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_name() == "SKILL.md") - { - let skill_path = entry.path(); - let skill_dir = skill_path - .parent() - .ok_or_else(|| CliError::Validation("SKILL.md has no parent directory".to_string()))?; - - match extract_skill_metadata(skill_dir, skill_path) { - Ok(skill) => { - info!("Found skill: {} ({})", skill.name, skill.id); - skills.push(skill); - } - Err(e) => { - warn!( - "Failed to extract skill from {}: {}", - skill_dir.display(), - e - ); - continue; - } - } - } - - Ok(skills) -} - -fn extract_skill_metadata(skill_dir: &Path, skill_file: &Path) -> CliResult { - // Read skill-project.toml if present (priority source) - let skill_project_path = skill_dir.join("skill-project.toml"); - let skill_metadata = if skill_project_path.exists() { - if let Ok(skill_project_content) = fs::read_to_string(&skill_project_path) { - #[derive(serde::Deserialize)] - struct SkillProjectToml { - metadata: Option, - } - - if let Ok(skill_project) = toml::from_str::(&skill_project_content) { - skill_project.metadata - } else { - None - } - } else { - None - } - } else { - None - }; - - // Read SKILL.md frontmatter (fallback source) - let skill_content = fs::read_to_string(skill_file) - .map_err(|e| CliError::Validation(format!("Failed to read SKILL.md: {}", e)))?; - - let frontmatter = parse_yaml_frontmatter(&skill_content).map_err(|e| { - CliError::Validation(format!("Failed to parse SKILL.md frontmatter: {}", e)) - })?; - - // Get skill ID from skill-project.toml (mandatory) - let id = skill_metadata - .as_ref() - .ok_or_else(|| { - CliError::Validation(format!( - "skill-project.toml is required but not found in: {}", - skill_dir.display() - )) - })? - .id - .clone() - .ok_or_else(|| { - CliError::Validation( - "skill-project.toml [metadata] section must have a non-empty 'id' field" - .to_string(), - ) - })?; - - // Read name from SKILL.md frontmatter only (for display purposes) - let name = frontmatter.name.clone(); - - let description = skill_metadata - .as_ref() - .and_then(|m| m.description.clone()) - .filter(|d| !d.is_empty()) - .unwrap_or_else(|| frontmatter.description.clone()); - - let version = if let Some(metadata) = skill_metadata.as_ref() { - metadata - .version - .clone() - .unwrap_or_else(|| frontmatter.version.unwrap_or_else(|| "1.0.0".to_string())) - } else { - frontmatter.version.unwrap_or_else(|| "1.0.0".to_string()) - }; - - let author = skill_metadata - .as_ref() - .and_then(|m| m.author.clone()) - .or_else(|| frontmatter.author.clone()); - - let download_url = skill_metadata.as_ref().and_then(|m| m.download_url.clone()); - - if skill_metadata.is_some() { - info!("Using metadata from skill-project.toml for skill: {}", id); - } - - Ok(MarketplaceSkill { - id, - name, - description, - version, - author, - download_url, - }) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - #[tokio::test] - async fn test_execute_registry_list_empty() { - let temp_dir = TempDir::new().unwrap(); - let original_dir = std::env::current_dir().ok(); - - // Helper struct to ensure directory is restored even if test panics - struct DirGuard(Option); - impl Drop for DirGuard { - fn drop(&mut self) { - if let Some(dir) = &self.0 { - let _ = std::env::set_current_dir(dir); - } - } - } - let _guard = DirGuard(original_dir); - - std::env::set_current_dir(temp_dir.path()).unwrap(); - - let args = RegistryArgs { - command: RegistryCommand::List, - }; - - // Should succeed even with no repositories - let result = execute_registry(args).await; - assert!(result.is_ok() || result.is_err()); - } - - #[tokio::test] - async fn test_execute_registry_remove_nonexistent() { - let temp_dir = TempDir::new().unwrap(); - - // Get original directory immediately and handle potential errors - let original_dir = match std::env::current_dir() { - Ok(dir) => dir, - Err(_) => { - // If we can't get current dir, assume we're in the project root - std::path::PathBuf::from(".") - } - }; - - // Helper struct to ensure directory is restored even if test panics - struct DirGuard(std::path::PathBuf); - impl Drop for DirGuard { - fn drop(&mut self) { - let _ = std::env::set_current_dir(&self.0); - } - } - let _guard = DirGuard(original_dir.clone()); - - std::env::set_current_dir(temp_dir.path()).unwrap(); - - // Create .claude directory and empty repositories.toml using absolute paths - let claude_dir = temp_dir.path().join(".claude"); - let repos_file = claude_dir.join("repositories.toml"); - fs::create_dir_all(&claude_dir).expect("Failed to create .claude directory"); - fs::write(&repos_file, "[repositories]").expect("Failed to write repositories.toml"); - - // Verify files exist before test - assert!(claude_dir.exists(), "Claude directory should exist"); - assert!(repos_file.exists(), "Repositories file should exist"); - - let args = RegistryArgs { - command: RegistryCommand::Remove { - name: "nonexistent".to_string(), - }, - }; - - let result = execute_registry(args).await; - // Should fail because repository doesn't exist - assert!(result.is_err()); - } -} diff --git a/src/cli/commands/registry/formatters.rs b/src/cli/commands/registry/formatters.rs new file mode 100644 index 00000000..2d6bb40a --- /dev/null +++ b/src/cli/commands/registry/formatters.rs @@ -0,0 +1,133 @@ +use crate::cli::error::CliResult; +use fastskill::core::repository::RepositoryConfig; +use fastskill::core::repository::RepositoryDefinition; +use fastskill::core::repository::RepositoryType; + +pub fn format_repository_list(repos: &[&RepositoryDefinition]) -> String { + if repos.is_empty() { + "No repositories configured.".to_string() + } else { + let mut output = format!("Configured Repositories ({}):\n", repos.len()); + for repo in repos { + let repo_type_str = repo_type_to_string(repo.repo_type.clone()); + output.push_str(&format!( + " • {} (type: {}, priority: {})\n", + repo.name, repo_type_str, repo.priority + )); + } + output + } +} + +pub fn format_repository_details(repo: &RepositoryDefinition) -> String { + let mut output = String::new(); + let repo_type_str = repo_type_to_string(repo.repo_type.clone()); + + output.push_str(&format!("Repository: {}\n", repo.name)); + output.push_str(&format!(" Type: {}\n", repo_type_str)); + output.push_str(&format!(" Priority: {}\n", repo.priority)); + + match &repo.config { + RepositoryConfig::GitMarketplace { url, branch, tag } => { + output.push_str(&format!(" URL: {}\n", url)); + if let Some(b) = branch { + output.push_str(&format!(" Branch: {}\n", b)); + } + if let Some(t) = tag { + output.push_str(&format!(" Tag: {}\n", t)); + } + } + RepositoryConfig::HttpRegistry { index_url } => { + output.push_str(&format!(" Index URL: {}\n", index_url)); + } + RepositoryConfig::ZipUrl { base_url } => { + output.push_str(&format!(" Base URL: {}\n", base_url)); + } + RepositoryConfig::Local { path } => { + output.push_str(&format!(" Path: {}\n", path.display())); + } + } + + if let Some(auth) = &repo.auth { + output.push_str(&format!(" Auth: {:?}\n", auth)); + } + + if let Some(storage) = &repo.storage { + output.push_str(&format!(" Storage: {:?}\n", storage)); + } + + output +} + +fn repo_type_to_string(repo_type: RepositoryType) -> &'static str { + match repo_type { + RepositoryType::GitMarketplace => "git-marketplace", + RepositoryType::HttpRegistry => "http-registry", + RepositoryType::ZipUrl => "zip-url", + RepositoryType::Local => "local", + } +} + +pub fn format_grid_output( + summaries: &[fastskill::core::registry_index::SkillSummary], + all_versions: bool, +) -> CliResult<()> { + let headers = if all_versions { + vec!["Scope", "Name", "Description", "Version", "Published"] + } else { + vec![ + "Scope", + "Name", + "Description", + "Latest Version", + "Published", + ] + }; + + let mut col_widths = vec![0; headers.len()]; + for (i, header) in headers.iter().enumerate() { + col_widths[i] = header.len(); + } + + for summary in summaries { + col_widths[0] = col_widths[0].max(summary.scope.len()); + col_widths[1] = col_widths[1].max(summary.name.len()); + let desc_len = summary.description.len().min(50); + col_widths[2] = col_widths[2].max(desc_len); + col_widths[3] = col_widths[3].max(summary.latest_version.len()); + col_widths[4] = col_widths[4].max(10); + } + + let header_row: Vec = headers + .iter() + .enumerate() + .map(|(i, h)| format!("{:width$}", h, width = col_widths[i])) + .collect(); + println!("\n{}", header_row.join(" ")); + println!("{}", "-".repeat(header_row.join(" ").len())); + + for summary in summaries { + let description = if summary.description.len() > 50 { + format!("{}...", &summary.description[..47]) + } else { + summary.description.clone() + }; + + let published = summary + .published_at + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| "N/A".to_string()); + + let row = [ + format!("{:width$}", summary.scope, width = col_widths[0]), + format!("{:width$}", summary.name, width = col_widths[1]), + format!("{:width$}", description, width = col_widths[2]), + format!("{:width$}", summary.latest_version, width = col_widths[3]), + format!("{:width$}", published, width = col_widths[4]), + ]; + println!("{}", row.join(" ")); + } + + println!(); + Ok(()) +} diff --git a/src/cli/commands/registry/helpers.rs b/src/cli/commands/registry/helpers.rs new file mode 100644 index 00000000..df72d247 --- /dev/null +++ b/src/cli/commands/registry/helpers.rs @@ -0,0 +1,124 @@ +use crate::cli::error::{CliError, CliResult}; +use fastskill::core::repository::{ + RepositoryAuth, RepositoryConfig, RepositoryManager, RepositoryType, +}; +use std::path::PathBuf; + +pub async fn load_repo_manager() -> CliResult { + let repos_path = crate::cli::config::get_repositories_toml_path() + .map_err(|e| CliError::Config(format!("Failed to find repositories.toml: {}", e)))?; + let mut repo_manager = RepositoryManager::new(repos_path); + repo_manager + .load() + .map_err(|e| CliError::Config(format!("Failed to load repositories: {}", e)))?; + Ok(repo_manager) +} + +pub fn resolve_repository_name( + manager: &RepositoryManager, + name: Option, +) -> CliResult { + if let Some(repo_name) = name { + Ok(repo_name) + } else { + manager + .get_default_repository() + .map(|r| r.name.clone()) + .ok_or_else(|| { + CliError::Config( + "No repository specified and no default repository configured".to_string(), + ) + }) + } +} + +pub fn parse_repository_type(repo_type: &str) -> CliResult { + match repo_type { + "git-marketplace" => Ok(RepositoryType::GitMarketplace), + "http-registry" => Ok(RepositoryType::HttpRegistry), + "zip-url" => Ok(RepositoryType::ZipUrl), + "local" => Ok(RepositoryType::Local), + _ => Err(CliError::Config(format!( + "Invalid repository type: {}. Use: git-marketplace, http-registry, zip-url, or local", + repo_type + ))), + } +} + +pub fn create_repository_config( + repo_type: RepositoryType, + url_or_path: String, + branch: Option, + tag: Option, +) -> RepositoryConfig { + match repo_type { + RepositoryType::GitMarketplace => RepositoryConfig::GitMarketplace { + url: url_or_path, + branch, + tag, + }, + RepositoryType::HttpRegistry => RepositoryConfig::HttpRegistry { + index_url: url_or_path, + }, + RepositoryType::ZipUrl => RepositoryConfig::ZipUrl { + base_url: url_or_path, + }, + RepositoryType::Local => RepositoryConfig::Local { + path: PathBuf::from(url_or_path), + }, + } +} + +pub fn parse_authentication( + auth_type: Option, + auth_env: Option, + auth_key_path: Option, + auth_username: Option, +) -> CliResult> { + if let Some(auth_t) = auth_type { + match auth_t.as_str() { + "pat" => { + let env_var = auth_env.ok_or_else(|| { + CliError::Config("--auth-env required for pat authentication".to_string()) + })?; + Ok(Some(RepositoryAuth::Pat { env_var })) + } + "ssh-key" | "ssh" => { + let key_path = auth_key_path.ok_or_else(|| { + CliError::Config("--auth-key-path required for ssh authentication".to_string()) + })?; + if auth_t == "ssh-key" { + Ok(Some(RepositoryAuth::SshKey { path: key_path })) + } else { + Ok(Some(RepositoryAuth::Ssh { key_path })) + } + } + "basic" => { + let username = auth_username.ok_or_else(|| { + CliError::Config( + "--auth-username required for basic authentication".to_string(), + ) + })?; + let password_env = auth_env.ok_or_else(|| { + CliError::Config("--auth-env required for basic authentication".to_string()) + })?; + Ok(Some(RepositoryAuth::Basic { + username, + password_env, + })) + } + "api_key" => { + let env_var = auth_env.ok_or_else(|| { + CliError::Config("--auth-env required for api_key authentication".to_string()) + })?; + Ok(Some(RepositoryAuth::ApiKey { env_var })) + } + _ => Err(CliError::Config(format!( + "Invalid auth type: {}. Use: pat, ssh-key, ssh, basic, api_key", + auth_t + ))), + } + } else { + Ok(None) + } +} diff --git a/src/cli/commands/registry/marketplace.rs b/src/cli/commands/registry/marketplace.rs new file mode 100644 index 00000000..8892ca9a --- /dev/null +++ b/src/cli/commands/registry/marketplace.rs @@ -0,0 +1,231 @@ +use crate::cli::error::{CliError, CliResult}; +use crate::cli::utils::messages; +use fastskill::core::manifest::MetadataSection; +use fastskill::core::metadata::parse_yaml_frontmatter; +use fastskill::core::sources::{ + ClaudeCodeMarketplaceJson, ClaudeCodeMetadata, ClaudeCodeOwner, ClaudeCodePlugin, + MarketplaceSkill, +}; +use std::fs; +use std::path::{Path, PathBuf}; +use toml; +use tracing::{info, warn}; +use walkdir::WalkDir; + +pub async fn execute_create( + path: PathBuf, + output: Option, + _base_url: Option, + name: Option, + owner_name: Option, + owner_email: Option, + description: Option, + version: Option, +) -> CliResult<()> { + let skill_dir = path + .canonicalize() + .map_err(|e| CliError::Validation(format!("Failed to resolve path: {}", e)))?; + + info!("Scanning directory for skills: {}", skill_dir.display()); + + let skills = scan_directory_for_skills(&skill_dir)?; + + if skills.is_empty() { + return Err(CliError::Validation(format!( + "No skills found in directory: {}", + skill_dir.display() + ))); + } + + let skills_count = skills.len(); + info!("Found {} skills", skills_count); + + let output_path = + output.unwrap_or_else(|| skill_dir.join(".claude-plugin").join("marketplace.json")); + + let repo_name = name + .or_else(|| { + skill_dir + .file_name() + .and_then(|n| n.to_str().map(|s| s.to_string())) + }) + .ok_or_else(|| { + CliError::Validation( + "Repository name is required. Use --name or ensure directory has a name." + .to_string(), + ) + })?; + + let skill_paths: Vec = skills + .iter() + .map(|skill| format!("./{}", skill.id)) + .collect(); + + let plugin = ClaudeCodePlugin { + name: repo_name.clone(), + description: description.clone(), + source: Some("./".to_string()), + strict: Some(false), + skills: skill_paths, + }; + + let marketplace = ClaudeCodeMarketplaceJson { + name: repo_name, + owner: if owner_name.is_some() || owner_email.is_some() { + Some(ClaudeCodeOwner { + name: owner_name.unwrap_or_else(|| "Unknown".to_string()), + email: owner_email, + }) + } else { + None + }, + metadata: if description.is_some() || version.is_some() { + Some(ClaudeCodeMetadata { + description, + version, + }) + } else { + None + }, + plugins: vec![plugin], + }; + + let json_content = serde_json::to_string_pretty(&marketplace).map_err(|e| { + CliError::Validation(format!("Failed to serialize marketplace.json: {}", e)) + })?; + + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent).map_err(|e| { + CliError::Validation(format!("Failed to create output directory: {}", e)) + })?; + } + + fs::write(&output_path, json_content) + .map_err(|e| CliError::Validation(format!("Failed to write marketplace.json: {}", e)))?; + + println!( + "{}", + messages::ok(&format!( + "Created marketplace.json: {}", + output_path.display() + )) + ); + println!(" Found {} skills", skills_count); + + Ok(()) +} + +pub fn scan_directory_for_skills(dir: &Path) -> CliResult> { + let mut skills = Vec::new(); + + for entry in WalkDir::new(dir) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name() == "SKILL.md") + { + let skill_path = entry.path(); + let skill_dir = skill_path + .parent() + .ok_or_else(|| CliError::Validation("SKILL.md has no parent directory".to_string()))?; + + match extract_skill_metadata(skill_dir, skill_path) { + Ok(skill) => { + info!("Found skill: {} ({})", skill.name, skill.id); + skills.push(skill); + } + Err(e) => { + warn!( + "Failed to extract skill from {}: {}", + skill_dir.display(), + e + ); + continue; + } + } + } + + Ok(skills) +} + +pub fn extract_skill_metadata(skill_dir: &Path, skill_file: &Path) -> CliResult { + let skill_project_path = skill_dir.join("skill-project.toml"); + let skill_metadata = if skill_project_path.exists() { + if let Ok(skill_project_content) = fs::read_to_string(&skill_project_path) { + #[derive(serde::Deserialize)] + struct SkillProjectToml { + metadata: Option, + } + + if let Ok(skill_project) = toml::from_str::(&skill_project_content) { + skill_project.metadata + } else { + None + } + } else { + None + } + } else { + None + }; + + let skill_content = fs::read_to_string(skill_file) + .map_err(|e| CliError::Validation(format!("Failed to read SKILL.md: {}", e)))?; + + let frontmatter = parse_yaml_frontmatter(&skill_content).map_err(|e| { + CliError::Validation(format!("Failed to parse SKILL.md frontmatter: {}", e)) + })?; + + let id = skill_metadata + .as_ref() + .ok_or_else(|| { + CliError::Validation(format!( + "skill-project.toml is required but not found in: {}", + skill_dir.display() + )) + })? + .id + .clone() + .ok_or_else(|| { + CliError::Validation( + "skill-project.toml [metadata] section must have a non-empty 'id' field" + .to_string(), + ) + })?; + + let name = frontmatter.name.clone(); + + let description = skill_metadata + .as_ref() + .and_then(|m| m.description.clone()) + .filter(|d| !d.is_empty()) + .unwrap_or_else(|| frontmatter.description.clone()); + + let version = if let Some(metadata) = skill_metadata.as_ref() { + metadata + .version + .clone() + .unwrap_or_else(|| frontmatter.version.unwrap_or_else(|| "1.0.0".to_string())) + } else { + frontmatter.version.unwrap_or_else(|| "1.0.0".to_string()) + }; + + let author = skill_metadata + .as_ref() + .and_then(|m| m.author.clone()) + .or_else(|| frontmatter.author.clone()); + + let download_url = skill_metadata.as_ref().and_then(|m| m.download_url.clone()); + + if skill_metadata.is_some() { + info!("Using metadata from skill-project.toml for skill: {}", id); + } + + Ok(MarketplaceSkill { + id, + name, + description, + version, + author, + download_url, + }) +} diff --git a/src/cli/commands/registry/mod.rs b/src/cli/commands/registry/mod.rs new file mode 100644 index 00000000..b2d97001 --- /dev/null +++ b/src/cli/commands/registry/mod.rs @@ -0,0 +1,333 @@ +//! Unified registry command implementation +//! +//! This command consolidates functionality from sources, registry, and repository commands +//! into a single unified interface for managing repositories and browsing skills. + +pub mod formatters; +pub mod helpers; +pub mod marketplace; +pub mod repo_ops; +pub mod skill_ops; + +use crate::cli::error::CliResult; +use clap::{Args, Subcommand}; +use std::path::PathBuf; + +#[derive(Debug, Args)] +pub struct RegistryArgs { + #[command(subcommand)] + pub command: RegistryCommand, +} + +#[derive(Debug, Subcommand)] +pub enum RegistryCommand { + /// List all configured repositories + List, + + /// Add a new repository + Add { + /// Repository name + name: String, + /// Repository type: git-marketplace, http-registry, zip-url, or local + #[arg(long)] + repo_type: String, + /// URL for git-marketplace or http-registry, base_url for zip-url, or path for local + url_or_path: String, + /// Priority (lower number = higher priority, default: 0) + #[arg(long)] + priority: Option, + /// Branch for git-marketplace + #[arg(long)] + branch: Option, + /// Tag for git-marketplace + #[arg(long)] + tag: Option, + /// Authentication type: pat, ssh-key, ssh, basic, or api_key + #[arg(long)] + auth_type: Option, + /// Environment variable for PAT, basic password, or API key + #[arg(long)] + auth_env: Option, + /// SSH key path (for ssh-key or ssh auth) + #[arg(long)] + auth_key_path: Option, + /// Username (for basic auth) + #[arg(long)] + auth_username: Option, + }, + + /// Remove a repository + Remove { + /// Repository name to remove + name: String, + }, + + /// Show repository details + Show { + /// Repository name + name: String, + }, + + /// Update repository metadata + Update { + /// Repository name to update + name: String, + /// New branch (for git-marketplace) + #[arg(long)] + branch: Option, + /// New priority + #[arg(long)] + priority: Option, + }, + + /// Test repository connectivity + Test { + /// Repository name to test + name: String, + }, + + /// Refresh repository cache + Refresh { + /// Repository name to refresh (if not specified, refreshes all) + name: Option, + }, + + /// List all skills in registry + ListSkills { + /// Repository name to list skills from (defaults to default repository if not specified) + #[arg(long)] + repository: Option, + /// Filter by scope (organization name) + #[arg(long)] + scope: Option, + /// Show all versions for each skill + #[arg(long)] + all_versions: bool, + /// Include pre-release versions + #[arg(long)] + include_pre_release: bool, + /// Output in JSON format + #[arg(long)] + json: bool, + /// Output in grid format (default) + #[arg(long)] + grid: bool, + }, + + /// Show skill details + ShowSkill { + /// Skill ID + skill_id: String, + /// Repository name (defaults to default repository if not specified) + #[arg(long)] + repository: Option, + }, + + /// List available versions for a skill + Versions { + /// Skill ID + skill_id: String, + /// Repository name (defaults to default repository if not specified) + #[arg(long)] + repository: Option, + }, + + /// Search skills in registry + Search { + /// Search query + query: String, + /// Repository name to search (searches all if not specified) + #[arg(long)] + repository: Option, + }, + + /// Create marketplace.json from a directory containing skills + Create { + /// Directory containing skills to scan + #[arg(short, long, default_value = ".")] + path: PathBuf, + /// Output file path (default: .claude-plugin/marketplace.json in the specified directory) + #[arg(short, long)] + output: Option, + /// Base URL for download links (optional) + #[arg(long)] + base_url: Option, + /// Repository name (required) + #[arg(long)] + name: Option, + /// Owner name (optional) + #[arg(long)] + owner_name: Option, + /// Owner email (optional) + #[arg(long)] + owner_email: Option, + /// Repository description (optional) + #[arg(long)] + description: Option, + /// Repository version (optional) + #[arg(long)] + version: Option, + }, +} + +pub async fn execute_registry(args: RegistryArgs) -> CliResult<()> { + match args.command { + RegistryCommand::List => repo_ops::execute_list().await, + RegistryCommand::Add { + name, + repo_type, + url_or_path, + priority, + branch, + tag, + auth_type, + auth_env, + auth_key_path, + auth_username, + } => { + repo_ops::execute_add( + name, + repo_type, + url_or_path, + priority, + branch, + tag, + auth_type, + auth_env, + auth_key_path, + auth_username, + ) + .await + } + RegistryCommand::Remove { name } => repo_ops::execute_remove(name).await, + RegistryCommand::Show { name } => repo_ops::execute_show(name).await, + RegistryCommand::Update { + name, + branch, + priority, + } => repo_ops::execute_update(name, branch, priority).await, + RegistryCommand::Test { name } => repo_ops::execute_test(name).await, + RegistryCommand::Refresh { name } => repo_ops::execute_refresh(name).await, + RegistryCommand::ListSkills { + repository, + scope, + all_versions, + include_pre_release, + json, + grid, + } => { + skill_ops::execute_list_skills( + repository, + scope, + all_versions, + include_pre_release, + json, + grid, + ) + .await + } + RegistryCommand::ShowSkill { + skill_id, + repository, + } => skill_ops::execute_show_skill(skill_id, repository).await, + RegistryCommand::Versions { + skill_id, + repository, + } => skill_ops::execute_versions(skill_id, repository).await, + RegistryCommand::Search { query, repository } => { + skill_ops::execute_search(query, repository).await + } + RegistryCommand::Create { + path, + output, + base_url, + name, + owner_name, + owner_email, + description, + version, + } => { + marketplace::execute_create( + path, + output, + base_url, + name, + owner_name, + owner_email, + description, + version, + ) + .await + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[tokio::test] + async fn test_execute_registry_list_empty() { + let temp_dir = TempDir::new().unwrap(); + let original_dir = std::env::current_dir().ok(); + + struct DirGuard(Option); + impl Drop for DirGuard { + fn drop(&mut self) { + if let Some(dir) = &self.0 { + let _ = std::env::set_current_dir(dir); + } + } + } + let _guard = DirGuard(original_dir); + + std::env::set_current_dir(temp_dir.path()).unwrap(); + + let args = RegistryArgs { + command: RegistryCommand::List, + }; + + let result = execute_registry(args).await; + assert!(result.is_ok() || result.is_err()); + } + + #[tokio::test] + async fn test_execute_registry_remove_nonexistent() { + let temp_dir = TempDir::new().unwrap(); + + let original_dir = match std::env::current_dir() { + Ok(dir) => dir, + Err(_) => std::path::PathBuf::from("."), + }; + + struct DirGuard(std::path::PathBuf); + impl Drop for DirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.0); + } + } + let _guard = DirGuard(original_dir.clone()); + + std::env::set_current_dir(temp_dir.path()).unwrap(); + + let claude_dir = temp_dir.path().join(".claude"); + let repos_file = claude_dir.join("repositories.toml"); + fs::create_dir_all(&claude_dir).expect("Failed to create .claude directory"); + fs::write(&repos_file, "[repositories]").expect("Failed to write repositories.toml"); + + assert!(claude_dir.exists(), "Claude directory should exist"); + assert!(repos_file.exists(), "Repositories file should exist"); + + let args = RegistryArgs { + command: RegistryCommand::Remove { + name: "nonexistent".to_string(), + }, + }; + + let result = execute_registry(args).await; + assert!(result.is_err()); + } +} diff --git a/src/cli/commands/registry/repo_ops.rs b/src/cli/commands/registry/repo_ops.rs new file mode 100644 index 00000000..9c29d3c8 --- /dev/null +++ b/src/cli/commands/registry/repo_ops.rs @@ -0,0 +1,185 @@ +use crate::cli::error::{CliError, CliResult}; +use crate::cli::utils::messages; +use std::path::PathBuf; + +pub async fn execute_list() -> CliResult<()> { + let repo_manager = super::helpers::load_repo_manager().await?; + let repos = repo_manager.list_repositories(); + println!("{}", super::formatters::format_repository_list(&repos)); + Ok(()) +} + +pub async fn execute_add( + name: String, + repo_type: String, + url_or_path: String, + priority: Option, + branch: Option, + tag: Option, + auth_type: Option, + auth_env: Option, + auth_key_path: Option, + auth_username: Option, +) -> CliResult<()> { + let mut repo_manager = super::helpers::load_repo_manager().await?; + + let repo_type_enum = super::helpers::parse_repository_type(&repo_type)?; + let config = + super::helpers::create_repository_config(repo_type_enum.clone(), url_or_path, branch, tag); + let auth = + super::helpers::parse_authentication(auth_type, auth_env, auth_key_path, auth_username)?; + + let repo_def = fastskill::core::repository::RepositoryDefinition { + name: name.clone(), + repo_type: repo_type_enum, + priority: priority.unwrap_or(0), + config, + auth, + storage: None, + }; + + repo_manager + .add_repository(name.clone(), repo_def.clone()) + .map_err(|e| CliError::Config(format!("Failed to add repository: {}", e)))?; + repo_manager + .save() + .map_err(|e| CliError::Config(format!("Failed to save repositories: {}", e)))?; + + println!("{}", messages::ok(&format!("Added repository: {}", name))); + Ok(()) +} + +pub async fn execute_remove(name: String) -> CliResult<()> { + let mut repo_manager = super::helpers::load_repo_manager().await?; + + repo_manager + .remove_repository(&name) + .map_err(|e| CliError::Config(format!("Failed to remove repository: {}", e)))?; + repo_manager + .save() + .map_err(|e| CliError::Config(format!("Failed to save repositories: {}", e)))?; + + println!("{}", messages::ok(&format!("Removed repository: {}", name))); + Ok(()) +} + +pub async fn execute_show(name: String) -> CliResult<()> { + let repo_manager = super::helpers::load_repo_manager().await?; + + let repo = repo_manager + .get_repository(&name) + .ok_or_else(|| CliError::Config(format!("Repository '{}' not found", name)))?; + + println!("{}", super::formatters::format_repository_details(repo)); + + Ok(()) +} + +pub async fn execute_update( + name: String, + branch: Option, + priority: Option, +) -> CliResult<()> { + let mut repo_manager = super::helpers::load_repo_manager().await?; + + let repo = repo_manager + .get_repository(&name) + .ok_or_else(|| CliError::Config(format!("Repository '{}' not found", name)))? + .clone(); + + let updated_config = if let Some(new_branch) = branch { + match &repo.config { + fastskill::core::repository::RepositoryConfig::GitMarketplace { + url, + branch: _, + tag, + } => fastskill::core::repository::RepositoryConfig::GitMarketplace { + url: url.clone(), + branch: Some(new_branch), + tag: tag.clone(), + }, + _ => repo.config.clone(), + } + } else { + repo.config.clone() + }; + + let updated_priority = priority.unwrap_or(repo.priority); + + repo_manager + .remove_repository(&name) + .map_err(|e| CliError::Config(format!("Failed to remove repository: {}", e)))?; + + let updated_repo = fastskill::core::repository::RepositoryDefinition { + name: repo.name.clone(), + repo_type: repo.repo_type, + priority: updated_priority, + config: updated_config, + auth: repo.auth, + storage: repo.storage, + }; + + repo_manager + .add_repository(name.clone(), updated_repo) + .map_err(|e| CliError::Config(format!("Failed to add repository: {}", e)))?; + repo_manager + .save() + .map_err(|e| CliError::Config(format!("Failed to save repositories: {}", e)))?; + + println!("{}", messages::ok(&format!("Updated repository: {}", name))); + Ok(()) +} + +pub async fn execute_test(name: String) -> CliResult<()> { + let repo_manager = super::helpers::load_repo_manager().await?; + + let _repo = repo_manager + .get_repository(&name) + .ok_or_else(|| CliError::Config(format!("Repository '{}' not found", name)))?; + + println!( + "{}", + messages::info(&format!("Testing repository: {}...", name)) + ); + + match repo_manager.get_client(&name).await { + Ok(client) => match client.list_skills().await { + Ok(skills) => { + println!( + "{}", + messages::ok(&format!( + "Repository '{}' is accessible ({} skills found)", + name, + skills.len() + )) + ); + } + Err(e) => { + return Err(CliError::Config(format!( + "Repository '{}' test failed: {}", + name, e + ))); + } + }, + Err(e) => { + return Err(CliError::Config(format!( + "Repository '{}' test failed: {}", + name, e + ))); + } + } + + Ok(()) +} + +pub async fn execute_refresh(name: Option) -> CliResult<()> { + if let Some(repo_name) = name { + println!( + "{}", + messages::ok(&format!("Refreshed cache for repository: {}", repo_name)) + ); + } else { + println!("{}", messages::ok("Refreshed cache for all repositories")); + } + Ok(()) +} diff --git a/src/cli/commands/registry/skill_ops.rs b/src/cli/commands/registry/skill_ops.rs new file mode 100644 index 00000000..d87a6f5a --- /dev/null +++ b/src/cli/commands/registry/skill_ops.rs @@ -0,0 +1,232 @@ +use crate::cli::error::{CliError, CliResult}; +use crate::cli::utils::messages; +use fastskill::core::registry_index::ListSkillsOptions; +use fastskill::core::repository::{CratesRegistryClient, RepositoryType}; + +pub async fn execute_list_skills( + repository: Option, + scope: Option, + all_versions: bool, + include_pre_release: bool, + json: bool, + grid: bool, +) -> CliResult<()> { + if json && grid { + return Err(CliError::Config( + "Cannot use both --json and --grid flags. Use only one.".to_string(), + )); + } + + if let Some(ref scope) = scope { + if scope.is_empty() { + return Err(CliError::Config( + "Scope cannot be empty. Use a valid organization name.".to_string(), + )); + } + if scope.contains('/') || scope.contains('\\') || scope.contains("..") { + return Err(CliError::Config( + format!( + "Invalid scope format: '{}'. Scope must be a valid organization name without path separators.", + scope + ) + )); + } + if !scope + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + return Err(CliError::Config( + format!( + "Invalid scope format: '{}'. Scope must contain only alphanumeric characters, hyphens, and underscores.", + scope + ) + )); + } + } + + let repo_manager = super::helpers::load_repo_manager().await?; + + let repo_name = super::helpers::resolve_repository_name(&repo_manager, repository)?; + + let repo_def = repo_manager + .get_repository(&repo_name) + .ok_or_else(|| CliError::Config(format!("Repository '{}' not found", repo_name)))?; + + if repo_def.repo_type != RepositoryType::HttpRegistry { + return Err(CliError::Config( + format!( + "Repository '{}' is not an HTTP registry. This command only works with HTTP registries.", + repo_name + ) + )); + } + + let _index_url = match &repo_def.config { + fastskill::core::repository::RepositoryConfig::HttpRegistry { index_url } => { + index_url.clone() + } + _ => { + return Err(CliError::Config( + "Repository does not have index_url configured".to_string(), + )); + } + }; + + println!( + "{}", + messages::info(&format!("Listing skills from repository: {}", repo_name)) + ); + + let http_client = CratesRegistryClient::new(repo_def) + .map_err(|e| CliError::Config(format!("Failed to create HTTP registry client: {}", e)))?; + + let options = ListSkillsOptions { + scope, + all_versions, + include_pre_release, + }; + + let summaries = http_client + .fetch_skills(&options) + .await + .map_err(|e| CliError::Config(format!("Failed to fetch skills from registry: {}", e)))?; + + if summaries.is_empty() { + println!("{}", messages::warning("No skills found in repository")); + return Ok(()); + } + + let output_format = if json { "json" } else { "grid" }; + match output_format { + "json" => { + let json_output = serde_json::to_string_pretty(&summaries) + .map_err(|e| CliError::Config(format!("Failed to serialize JSON: {}", e)))?; + println!("{}", json_output); + } + "grid" => { + super::formatters::format_grid_output(&summaries, all_versions)?; + } + _ => unreachable!(), + } + + Ok(()) +} + +pub async fn execute_show_skill(skill_id: String, repository: Option) -> CliResult<()> { + let repo_manager = super::helpers::load_repo_manager().await?; + + let repo_name = super::helpers::resolve_repository_name(&repo_manager, repository)?; + + println!( + "{}", + messages::info(&format!("Fetching skill: {} from {}", skill_id, repo_name)) + ); + + let client = repo_manager + .get_client(&repo_name) + .await + .map_err(|e| CliError::Config(format!("Failed to get repository client: {}", e)))?; + + match client.get_skill(&skill_id, None).await { + Ok(Some(skill)) => { + println!("\nSkill: {}", skill.name); + println!("Version: {}", skill.version); + if !skill.description.is_empty() { + println!("Description: {}", skill.description); + } + if let Some(author) = &skill.author { + println!("Author: {}", author); + } + } + Ok(None) => { + println!( + "{}", + messages::warning(&format!("Skill '{}' not found in repository", skill_id)) + ); + } + Err(e) => { + return Err(CliError::Config(format!("Failed to get skill: {}", e))); + } + } + + Ok(()) +} + +pub async fn execute_versions(skill_id: String, repository: Option) -> CliResult<()> { + let repo_manager = super::helpers::load_repo_manager().await?; + + let repo_name = super::helpers::resolve_repository_name(&repo_manager, repository)?; + + println!( + "{}", + messages::info(&format!( + "Fetching versions for: {} from {}", + skill_id, repo_name + )) + ); + + let client = repo_manager + .get_client(&repo_name) + .await + .map_err(|e| CliError::Config(format!("Failed to get repository client: {}", e)))?; + + match client.get_versions(&skill_id).await { + Ok(versions) => { + if versions.is_empty() { + println!( + "{}", + messages::warning(&format!("No versions found for skill: {}", skill_id)) + ); + return Ok(()); + } + + println!("\nAvailable versions:"); + for version in versions { + println!(" - {}", version); + } + Ok(()) + } + Err(e) => Err(CliError::Config(format!("Failed to get versions: {}", e))), + } +} + +pub async fn execute_search(query: String, repository: Option) -> CliResult<()> { + let repo_manager = super::helpers::load_repo_manager().await?; + + println!( + "{}", + messages::info(&format!("Searching repositories for: {}", query)) + ); + + let repos = if let Some(repo_name) = repository { + vec![repo_manager + .get_repository(&repo_name) + .ok_or_else(|| CliError::Config(format!("Repository '{}' not found", repo_name)))?] + } else { + repo_manager.list_repositories() + }; + + let mut all_results = Vec::new(); + + for repo in repos { + match repo_manager.get_client(&repo.name).await { + Ok(client) => { + if let Ok(results) = client.search(&query).await { + all_results.extend(results); + } + } + Err(_) => continue, + } + } + + if all_results.is_empty() { + println!("{}", messages::warning("No results found")); + } else { + println!("\nFound {} result(s):", all_results.len()); + for result in all_results { + println!(" - {}: {}", result.name, result.description); + } + } + + Ok(()) +}