diff --git a/README.md b/README.md index a6377ec..6e88e2f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,26 @@ The Railway CLI allows you to: And more. +## Agent Skills + +Install [Railway agent skills](https://agentskills.io) for AI coding tools (Claude Code, Cursor, Codex, OpenCode, and more): + +```bash +railway skills +``` + +Use `--agent` to target a specific tool: + +```bash +railway skills --agent claude-code +``` + +You can also install via [skills.sh](https://skills.sh/railwayapp/railway-skills/use-railway): + +```bash +npx skills add https://github.com/railwayapp/railway-skills --skill use-railway +``` + ## Documentation [View the CLI guide](https://docs.railway.com/guides/cli) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index af21b6d..188e508 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -37,6 +37,7 @@ pub mod run; pub mod scale; pub mod service; pub mod shell; +pub mod skills; pub mod ssh; pub mod starship; pub mod status; diff --git a/src/commands/skills.rs b/src/commands/skills.rs new file mode 100644 index 0000000..07053e0 --- /dev/null +++ b/src/commands/skills.rs @@ -0,0 +1,370 @@ +use super::*; +use crate::consts::get_user_agent; +use crate::util::progress::{create_spinner, fail_spinner, success_spinner}; +use flate2::read::GzDecoder; +use std::collections::HashMap; +use std::io::{Cursor, Read}; +use std::path::{Path, PathBuf}; + +const TARBALL_URL: &str = + "https://github.com/railwayapp/railway-skills/archive/refs/heads/main.tar.gz"; +const SKILLS_PATH_PREFIX: &str = "plugins/railway/skills/"; + +/// Install Railway agent skills for AI coding tools (Claude Code, Cursor, Codex, OpenCode, and all tools that support .agents/skills) +/// +/// Always installs to ~/.agents/skills. Additionally installs to any detected tool directories (e.g. ~/.claude/skills, ~/.cursor/skills). Use --agent to target specific tools instead of auto-detection. +#[derive(Parser)] +pub struct Args { + #[clap(subcommand)] + command: Option, + + /// Target specific agent(s) instead of all detected (e.g. --agent claude-code) + #[clap(long, global = true)] + agent: Vec, +} + +#[derive(Parser)] +enum Commands { + /// Install Railway agent skills for AI coding tools (Claude Code, Cursor, Codex, OpenCode, and all tools that support .agents/skills) + /// + /// Always installs to ~/.agents/skills. Additionally installs to any detected tool directories (e.g. ~/.claude/skills, ~/.cursor/skills). Use --agent to target specific tools instead of auto-detection. + #[clap(alias = "update")] + Install, + /// Remove Railway skills from all tools + Remove, +} + +#[derive(Clone)] +struct CodingTool { + slug: &'static str, + name: &'static str, + global_parent: PathBuf, + skills_dir_name: &'static str, +} + +struct InstallTarget { + tool_name: String, + skills_dir: PathBuf, +} + +type SkillFiles = HashMap)>>; + +pub async fn command(args: Args) -> Result<()> { + match args.command { + None | Some(Commands::Install) => install_skills(&args.agent).await, + Some(Commands::Remove) => remove_skills(&args.agent).await, + } +} + +fn coding_tools(home: &Path) -> Vec { + vec![ + CodingTool { + slug: "universal", + name: "Universal (.agents)", + global_parent: home.join(".agents"), + skills_dir_name: "skills", + }, + CodingTool { + slug: "claude-code", + name: "Claude Code", + global_parent: home.join(".claude"), + skills_dir_name: "skills", + }, + CodingTool { + slug: "codex", + name: "OpenAI Codex", + global_parent: home.join(".codex"), + skills_dir_name: "skills", + }, + CodingTool { + slug: "opencode", + name: "OpenCode", + global_parent: home.join(".config").join("opencode"), + skills_dir_name: "skills", + }, + CodingTool { + slug: "cursor", + name: "Cursor", + global_parent: home.join(".cursor"), + skills_dir_name: "skills", + }, + ] +} + +fn resolve_tools(home: &Path, agent_filter: &[String]) -> Result> { + let all_tools = coding_tools(home); + + if agent_filter.is_empty() { + // "agents" (universal) is always included; others require their config dir to exist. + Ok(all_tools + .into_iter() + .filter(|tool| tool.slug == "universal" || tool.global_parent.is_dir()) + .collect()) + } else { + let mut selected = Vec::new(); + for slug in agent_filter { + match all_tools.iter().find(|t| t.slug == slug.as_str()) { + Some(t) => selected.push(t.clone()), + None => { + let valid = all_tools + .iter() + .map(|t| t.slug) + .collect::>() + .join(", "); + bail!("Unknown agent: '{}'\n\nValid agents: {}", slug, valid); + } + } + } + Ok(selected) + } +} + +fn build_targets(tools: &[CodingTool]) -> Vec { + tools + .iter() + .map(|tool| InstallTarget { + tool_name: tool.name.to_string(), + skills_dir: tool.global_parent.join(tool.skills_dir_name), + }) + .collect() +} + +fn print_target_summary(action: &str, targets: &[InstallTarget]) { + let target_names = targets + .iter() + .map(|target| target.tool_name.as_str()) + .collect::>() + .join(", "); + + println!("{} {}\n", action.bold(), target_names); +} + +async fn download_tarball() -> Result> { + let client = reqwest::Client::new(); + let response = client + .get(TARBALL_URL) + .header("User-Agent", get_user_agent()) + .send() + .await + .context("Failed to download Railway skills")?; + + if !response.status().is_success() { + bail!( + "Failed to download Railway skills: HTTP {}", + response.status() + ); + } + + Ok(response + .bytes() + .await + .context("Failed to read response body")? + .to_vec()) +} + +/// Extract all skills from the tarball, grouped by skill name. +/// Returns a map of skill_name -> Vec<(relative_path, file_contents)>. +fn extract_skill_files(tarball_bytes: &[u8]) -> Result { + let decoder = GzDecoder::new(Cursor::new(tarball_bytes)); + let mut archive = tar::Archive::new(decoder); + let mut skills: SkillFiles = HashMap::new(); + + for entry in archive + .entries() + .context("Failed to read tarball entries")? + { + let mut entry = entry.context("Failed to read tarball entry")?; + let path_str = entry + .path() + .context("Failed to read entry path")? + .to_string_lossy() + .into_owned(); + + if let Some(pos) = path_str.find(SKILLS_PATH_PREFIX) { + let after_prefix = &path_str[pos + SKILLS_PATH_PREFIX.len()..]; + + // Split into skill_name/relative_path + let Some(slash_pos) = after_prefix.find('/') else { + continue; + }; + let skill_name = &after_prefix[..slash_pos]; + let relative = &after_prefix[slash_pos + 1..]; + + if skill_name.is_empty() || relative.is_empty() || entry.header().entry_type().is_dir() + { + continue; + } + + let mut contents = Vec::new(); + entry + .read_to_end(&mut contents) + .context("Failed to read file from tarball")?; + + skills + .entry(skill_name.to_string()) + .or_default() + .push((PathBuf::from(relative), contents)); + } + } + + if skills.is_empty() { + bail!("No skills found in downloaded repository"); + } + + Ok(skills) +} + +fn write_skills_to_target(target: &InstallTarget, skills: &SkillFiles) -> Result<()> { + for (skill_name, files) in skills { + let dest = target.skills_dir.join(skill_name); + + if let Err(e) = std::fs::remove_dir_all(&dest) { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(e).with_context(|| { + format!("Failed to remove existing skill at {}", dest.display()) + }); + } + } + + for (relative_path, contents) in files { + let file_path = dest.join(relative_path); + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory {}", parent.display()))?; + } + std::fs::write(&file_path, contents) + .with_context(|| format!("Failed to write {}", file_path.display()))?; + } + } + + Ok(()) +} + +async fn install_skills(agent_filter: &[String]) -> Result<()> { + let home = dirs::home_dir().context("could not determine home directory")?; + let tools = resolve_tools(&home, agent_filter)?; + let targets = build_targets(&tools); + + println!("\n{}\n", "Railway Skills".bold()); + print_target_summary("Installing to:", &targets); + + let mut spinner = create_spinner("Downloading skills...".to_string()); + let tarball_bytes = match download_tarball().await { + Ok(bytes) => { + success_spinner(&mut spinner, "Downloaded skills".to_string()); + bytes + } + Err(e) => { + fail_spinner(&mut spinner, "Failed to download skills".to_string()); + return Err(e); + } + }; + + let skills = extract_skill_files(&tarball_bytes)?; + let mut skill_names: Vec<&String> = skills.keys().collect(); + skill_names.sort(); + + println!(); + + for target in &targets { + std::fs::create_dir_all(&target.skills_dir).with_context(|| { + format!( + "Failed to create skills directory {}", + target.skills_dir.display() + ) + })?; + + write_skills_to_target(target, &skills)?; + + for skill_name in &skill_names { + let skill_path = target.skills_dir.join(skill_name); + println!( + "{} {}: installed {} \u{2192} {}", + "\u{2713}".green(), + target.tool_name.bold(), + skill_name.green(), + skill_path.display().to_string().cyan() + ); + } + } + + println!("\n{}", "Skills installed successfully!".green().bold()); + println!( + "{} You may need to restart your tool(s) to load skills.\n", + "!".yellow().bold() + ); + + Ok(()) +} + +// Remove fetches the skill list from the upstream repo rather than keeping a +// local manifest. The skills/ directory is shared with other providers, so we +// can't blindly delete everything — we need to know which subdirectories are +// ours. Using the repo as the source of truth avoids stale manifests when +// skills are renamed upstream. +async fn remove_skills(agent_filter: &[String]) -> Result<()> { + let home = dirs::home_dir().context("could not determine home directory")?; + let tools = resolve_tools(&home, agent_filter)?; + let targets = build_targets(&tools); + + println!("\n{}\n", "Railway Skills".bold()); + print_target_summary("Removing from:", &targets); + + let mut spinner = create_spinner("Fetching skill list...".to_string()); + let tarball_bytes = match download_tarball().await { + Ok(bytes) => { + success_spinner(&mut spinner, "Fetched skill list".to_string()); + bytes + } + Err(e) => { + fail_spinner(&mut spinner, "Failed to fetch skill list".to_string()); + return Err(e); + } + }; + + let skills = extract_skill_files(&tarball_bytes)?; + let mut skill_names: Vec<&String> = skills.keys().collect(); + skill_names.sort(); + + println!(); + + let mut removed_any = false; + + for target in &targets { + for skill_name in &skill_names { + let skill_dir = target.skills_dir.join(skill_name); + match std::fs::remove_dir_all(&skill_dir) { + Ok(()) => { + println!( + "{} {}: removed {}", + "\u{2713}".green(), + target.tool_name.bold(), + skill_name.red() + ); + removed_any = true; + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + println!( + "{} {}: {} not installed, skipping", + "-".dimmed(), + target.tool_name, + skill_name + ); + } + Err(e) => { + return Err(e).with_context(|| { + format!("Failed to remove skill at {}", skill_dir.display()) + }); + } + } + } + } + + if removed_any { + println!("\n{}\n", "Skills removed successfully.".green().bold()); + } else { + println!("\n{}\n", "No skills were installed.".dimmed()); + } + + Ok(()) +} diff --git a/src/macros.rs b/src/macros.rs index 0647435..0e54d5a 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -9,7 +9,10 @@ macro_rules! commands { .about("Railway CLI") .author(clap::crate_authors!()) .propagate_version(true) - .about(clap::crate_description!()) + .about(concat!( + clap::crate_description!(), + "\n\nTip: Using an AI coding agent? Run `railway skills install` to enhance it with Railway expertise — deploying, debugging, managing services." + )) .long_about(None) .version(clap::crate_version!()); $( diff --git a/src/main.rs b/src/main.rs index 62e769f..96f9a8f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,6 +54,7 @@ commands!( run(local), service, shell, + skills, ssh, starship, status,