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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
225 changes: 225 additions & 0 deletions crates/runbox-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,45 @@ RELATED COMMANDS:
#[command(subcommand)]
command: CreateCommands,
},
/// Manage skills (export, list) for AI coding assistants
#[command(after_help = "\
EXAMPLES:
# List available skills
runbox skill list

# Export a skill with installation guides
runbox skill export runbox-cli --output ./exported-skill

# Show skill details
runbox skill show runbox-cli

OUTPUT STRUCTURE:
exported-skill/
├── SKILL.md # The skill content
├── references/ # Reference files (if any)
├── examples/ # Example files (if any)
├── INSTALL.md # Unified install guide
├── install/
│ ├── claude-code.md # Claude Code installation
│ ├── opencode.md # OpenCode installation
│ ├── gemini.md # Gemini CLI installation
│ ├── codex.md # Codex installation
│ └── cursor.md # Cursor installation
└── install.sh # Auto-install script

SUPPORTED PLATFORMS:
- Claude Code (~/.claude/skills/)
- OpenCode (~/.opencode/skills/)
- Gemini CLI (project GEMINI.md)
- Codex (AGENTS.md)
- Cursor (~/.cursor/rules/)

RELATED COMMANDS:
runbox template list List available templates")]
Skill {
#[command(subcommand)]
command: SkillCommands,
},
#[command(after_help = "\
EXAMPLES:
# Show the complete tutorial
Expand Down Expand Up @@ -946,6 +985,52 @@ EXAMPLES:
},
}

#[derive(Subcommand)]
enum SkillCommands {
/// List all available skills from all platforms
#[command(after_help = "\
EXAMPLES:
runbox skill list

OUTPUT:
NAME PLATFORM PATH
────────────────────────────────────────────────────────────
runbox-cli Claude Code ~/.claude/skills/runbox-cli
daily-progress Claude Code ~/.claude/skills/daily-progress")]
List,

/// Show details about a specific skill
#[command(after_help = "\
EXAMPLES:
runbox skill show runbox-cli
runbox skill show daily-progress")]
Show {
/// Skill name
skill_name: String,
},

/// Export a skill with platform-specific installation guides
#[command(after_help = "\
EXAMPLES:
# Export to a directory
runbox skill export runbox-cli --output ./my-skill

# The output directory will contain:
# - SKILL.md (the skill file)
# - references/ (if any)
# - examples/ (if any)
# - INSTALL.md (unified guide)
# - install/ (platform-specific guides)
# - install.sh (auto-install script)")]
Export {
/// Skill name to export
skill_name: String,
/// Output directory
#[arg(short, long)]
output: PathBuf,
},
}

fn main() -> Result<()> {
let cli = Cli::parse();
let storage = if let Ok(home) = std::env::var("RUNBOX_HOME") {
Expand Down Expand Up @@ -1077,6 +1162,11 @@ fn main() -> Result<()> {
Commands::Create { command } => match command {
CreateCommands::Record { from_file } => cmd_create_record(&storage, from_file),
},
Commands::Skill { command } => match command {
SkillCommands::List => cmd_skill_list(),
SkillCommands::Show { skill_name } => cmd_skill_show(&skill_name),
SkillCommands::Export { skill_name, output } => cmd_skill_export(&skill_name, &output),
},
Commands::Daemon { command } => match command {
DaemonCommands::Start => cmd_daemon_start(),
DaemonCommands::Stop => cmd_daemon_stop(),
Expand Down Expand Up @@ -3021,3 +3111,138 @@ fn cmd_create_record(storage: &Storage, from_file: Option<String>) -> Result<()>

Ok(())
}

// === Skill Commands ===

fn cmd_skill_list() -> Result<()> {
use runbox_core::{find_skills, Platform};

let skills = find_skills();

if skills.is_empty() {
println!("No skills found.");
println!();
println!("Skills are searched in the following locations:");
for platform in Platform::all() {
if let Some(dir) = platform.skill_dir() {
println!(" {} - {}", platform.name(), dir.display());
}
}
return Ok(());
}

// Print header
println!(
"{:<25} {:<15} {}",
"NAME", "PLATFORM", "PATH"
);
println!("{}", "─".repeat(80));

// Print each skill
for (platform, path, name) in &skills {
let path_str = path.to_string_lossy();
let short_path = if path_str.len() > 40 {
format!("...{}", &path_str[path_str.len() - 37..])
} else {
path_str.to_string()
};
println!(
"{:<25} {:<15} {}",
name,
platform.name(),
short_path
);
}

println!();
println!("{} skill(s) found", skills.len());

Ok(())
}

fn cmd_skill_show(skill_name: &str) -> Result<()> {
use runbox_core::{find_skill_by_name, Skill};

let (platform, path) = find_skill_by_name(skill_name)
.ok_or_else(|| anyhow::anyhow!("Skill not found: {}", skill_name))?;

let skill = Skill::load(&path)?;

println!("Skill: {}", skill.metadata.name);
println!("Platform: {}", platform.name());
println!("Path: {}", path.display());
if let Some(ref version) = skill.metadata.version {
println!("Version: {}", version);
}
println!();
println!("Description:");
println!(" {}", skill.metadata.description);
println!();

if !skill.references.is_empty() {
println!("References ({}):", skill.references.len());
for ref_path in &skill.references {
println!(" - {}", ref_path.display());
}
println!();
}

if !skill.examples.is_empty() {
println!("Examples ({}):", skill.examples.len());
for ex_path in &skill.examples {
println!(" - {}", ex_path.display());
}
println!();
}

println!("Content preview (first 20 lines):");
println!("{}", "─".repeat(60));
for (i, line) in skill.content.lines().take(20).enumerate() {
println!("{:3} │ {}", i + 1, line);
}
if skill.content.lines().count() > 20 {
println!("... ({} more lines)", skill.content.lines().count() - 20);
}

Ok(())
}

fn cmd_skill_export(skill_name: &str, output: &Path) -> Result<()> {
use runbox_core::{find_skill_by_name, Skill};

let (_platform, path) = find_skill_by_name(skill_name)
.ok_or_else(|| anyhow::anyhow!("Skill not found: {}", skill_name))?;

let skill = Skill::load(&path)?;

println!("Exporting skill: {}", skill.metadata.name);
println!("Source: {}", path.display());
println!("Output: {}", output.display());
println!();

let result = skill.export(output)?;

println!("Export complete!");
println!();
println!("Created files:");
println!(" SKILL.md - Main skill file");
if result.references_count > 0 {
println!(" references/ - {} reference file(s)", result.references_count);
}
if result.examples_count > 0 {
println!(" examples/ - {} example file(s)", result.examples_count);
}
println!(" INSTALL.md - Unified installation guide");
println!(" install/ - Platform-specific guides");
println!(" claude-code.md");
println!(" opencode.md");
println!(" gemini.md");
println!(" codex.md");
println!(" cursor.md");
println!(" install.sh - Auto-install script");
println!();
println!("To install, run:");
println!(" cd {} && ./install.sh", output.display());

Ok(())
}
49 changes: 49 additions & 0 deletions crates/runbox-cli/tests/skill_export.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::tempdir;
use std::fs;

#[test]
fn test_skill_list_help() {
let mut cmd = Command::cargo_bin("runbox").unwrap();
cmd.arg("skill").arg("list").arg("--help");
cmd.assert()
.success()
.stdout(predicate::str::contains("List all available skills"));
}

#[test]
fn test_skill_export_help() {
let mut cmd = Command::cargo_bin("runbox").unwrap();
cmd.arg("skill").arg("export").arg("--help");
cmd.assert()
.success()
.stdout(predicate::str::contains("Export a skill with platform-specific installation guides"));
}

#[test]
fn test_skill_export_missing_skill() {
let tmp = tempdir().unwrap();
let output = tmp.path().join("output");

let mut cmd = Command::cargo_bin("runbox").unwrap();
cmd.arg("skill")
.arg("export")
.arg("nonexistent-skill-12345")
.arg("--output")
.arg(&output);
cmd.assert()
.failure()
.stderr(predicate::str::contains("Skill not found"));
}

#[test]
fn test_skill_show_missing_skill() {
let mut cmd = Command::cargo_bin("runbox").unwrap();
cmd.arg("skill")
.arg("show")
.arg("nonexistent-skill-12345");
cmd.assert()
.failure()
.stderr(predicate::str::contains("Skill not found"));
}
1 change: 1 addition & 0 deletions crates/runbox-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ description = "Core types and logic for runbox"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = "0.9"
uuid = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
Expand Down
3 changes: 3 additions & 0 deletions crates/runbox-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ pub mod local_storage;
pub mod record;
pub mod task;
pub mod index;
pub mod skill;
pub mod skill_export;

pub use binding::BindingResolver;
pub use config::{ConfigResolver, ConfigSource, ResolvedValue, RunboxConfig, VerboseLogger};
Expand All @@ -36,3 +38,4 @@ pub use local_storage::{LayeredStorage, Scope, locate_local_runbox_dir};
pub use record::{Record, RecordCommand, RecordGitState, RecordValidationError};
pub use task::{Task, TaskHandle, TaskRuntime, TaskStatus};
pub use index::{EntityType, Index, IndexedEntity};
pub use skill::{find_skill_by_name, find_skills, ExportResult, Platform, Skill, SkillError, SkillMetadata};
Loading