mirror of
https://github.com/railwayapp/cli
synced 2026-04-21 14:07:23 +00:00
feat: add railway skills command for installing agent skills (#847)
Installs Railway agent skills so AI coding agents (Claude Code, Cursor, Codex, OpenCode, and any tool that supports .agents/skills) can work with Railway more effectively. - `railway skills install` (alias: `update`) installs globally to ~/.agents/skills plus any detected tool directories - `railway skills remove` removes installed skills - `--agent <slug>` targets specific tools instead of auto-detection - Root-level `railway --help` surfaces a tip pointing users at the command Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b1028d453c
commit
4729ef2808
5 changed files with 396 additions and 1 deletions
20
README.md
20
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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
370
src/commands/skills.rs
Normal file
370
src/commands/skills.rs
Normal file
|
|
@ -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<Commands>,
|
||||
|
||||
/// Target specific agent(s) instead of all detected (e.g. --agent claude-code)
|
||||
#[clap(long, global = true)]
|
||||
agent: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<String, Vec<(PathBuf, Vec<u8>)>>;
|
||||
|
||||
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<CodingTool> {
|
||||
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<Vec<CodingTool>> {
|
||||
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::<Vec<_>>()
|
||||
.join(", ");
|
||||
bail!("Unknown agent: '{}'\n\nValid agents: {}", slug, valid);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(selected)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_targets(tools: &[CodingTool]) -> Vec<InstallTarget> {
|
||||
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::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
println!("{} {}\n", action.bold(), target_names);
|
||||
}
|
||||
|
||||
async fn download_tarball() -> Result<Vec<u8>> {
|
||||
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<SkillFiles> {
|
||||
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(())
|
||||
}
|
||||
|
|
@ -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!());
|
||||
$(
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ commands!(
|
|||
run(local),
|
||||
service,
|
||||
shell,
|
||||
skills,
|
||||
ssh,
|
||||
starship,
|
||||
status,
|
||||
|
|
|
|||
Loading…
Reference in a new issue