From 665d4cb1045951ad82564e22af63f5aa27772872 Mon Sep 17 00:00:00 2001 From: cpendery Date: Mon, 13 Jul 2026 19:49:07 -0700 Subject: [PATCH] feat: simplify skill addition command Signed-off-by: cpendery --- Cargo.lock | 37 ++++++++++ Cargo.toml | 1 + README.md | 6 +- src/cli.rs | 19 ++++- src/main.rs | 6 +- src/skill.rs | 193 +++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 255 insertions(+), 7 deletions(-) create mode 100644 src/skill.rs diff --git a/Cargo.lock b/Cargo.lock index fab617f..299d0ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,6 +198,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -244,6 +257,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "thiserror 1.0.69", +] + [[package]] name = "dirs" version = "6.0.0" @@ -277,6 +301,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "errno" version = "0.3.14" @@ -474,6 +504,12 @@ dependencies = [ "libc", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -749,6 +785,7 @@ dependencies = [ "anyhow", "clap", "crossterm", + "dialoguer", "dirs", "flate2", "interprocess", diff --git a/Cargo.toml b/Cargo.toml index 1f48dda..b15c516 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,3 +27,4 @@ regex = "1.12.4" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" crossterm = "0.28" +dialoguer = { version = "0.11", default-features = false } diff --git a/README.md b/README.md index 44d43fa..5ad0364 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,11 @@ shell-use wait exit ### Skill quick start ```sh -# ~/.agents or ~/.claude instead to install it for every repo. -mkdir -p .agents/skills/shell-use && shell-use skill > .agents/skills/shell-use/SKILL.md # copilot / codex -mkdir -p .claude/skills/shell-use && shell-use skill > .claude/skills/shell-use/SKILL.md # copilot / claude +shell-use skill --add ``` +Adds the `shell-use` skill to the location the user selects in the TUI. + Each command returns a stable exit code (see [Exit codes](#exit-codes)), so an agent can tell an assertion failure from a missing session without scraping text. ## Programmatic usage diff --git a/src/cli.rs b/src/cli.rs index c3e096c..623ed51 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -193,8 +193,12 @@ pub enum Command { /// Versioned via `schema_version`; lists every command, flag, type, enum, /// default, and the exit-code taxonomy. Generated from the CLI definition. AgentContext, - /// Print the long-form agent skill manifest (SKILL.md). - Skill, + /// Print or install the long-form agent skill manifest (SKILL.md). + Skill { + /// Interactively install the skill by choosing its scope and agent directory. + #[arg(long)] + add: bool, + }, /// Internal: run the session daemon. #[command(name = "__daemon", hide = true)] InternalDaemon, @@ -225,6 +229,17 @@ impl SignalArg { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn skill_accepts_the_add_flag() { + let cli = Cli::try_parse_from(["shell-use", "skill", "--add"]).expect("parse skill"); + assert!(matches!(cli.command, Some(Command::Skill { add: true }))); + } +} + #[derive(Subcommand)] pub enum DaemonCmd { /// Show daemon status, socket, and log path. diff --git a/src/main.rs b/src/main.rs index 8626f9b..890614d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod monitor; mod protocol; mod render; mod shell; +mod skill; mod terminal; mod trace; @@ -48,7 +49,8 @@ fn main() { println!("{}", agent_context::render()); 0 } - Command::Skill => { + Command::Skill { add: true } => skill::add(SKILL_MD), + Command::Skill { add: false } => { print!("{SKILL_MD}"); 0 } @@ -463,7 +465,7 @@ EXPECT expect text \"T\" [--regex --full --not --fg C --bg C --timeout MS]\n\ RECORD sessions auto-record; get-recording [session] > out.cast (asciinema v2)\n\ play with `asciinema play out.cast`, render GIF with `agg out.cast out.gif`\n\ WATCH monitor (live full-color view in another terminal; q/Esc/Ctrl-C to detach)\n\ -AGENT agent-context (JSON CLI schema) | skill (workflow guide)\n\ +AGENT agent-context (JSON CLI schema) | skill [--add] (workflow guide)\n\ GLOBAL --session NAME | --json | --verbose (log PTY traffic to ~/.shell-use/.log)\n\ EXIT 0 ok | 1 assertion/wait failed | 2 usage | 3 no session | 4 daemon/IPC | 5 internal\n\ " diff --git a/src/skill.rs b/src/skill.rs new file mode 100644 index 0000000..f7cf0d2 --- /dev/null +++ b/src/skill.rs @@ -0,0 +1,193 @@ +use std::path::{Path, PathBuf}; + +use dialoguer::{theme::ColorfulTheme, Select}; + +const SKILL_NAME: &str = "shell-use"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InstallScope { + Repository, + Global, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AgentDirectory { + Agents, + Claude, +} + +impl AgentDirectory { + fn name(self) -> &'static str { + match self { + Self::Agents => ".agents", + Self::Claude => ".claude", + } + } +} + +pub fn add(manifest: &str) -> i32 { + match add_interactive(manifest) { + Ok(()) => 0, + Err(message) => { + eprintln!("shell-use skill: {message}"); + 1 + } + } +} + +fn add_interactive(manifest: &str) -> Result<(), String> { + let theme = ColorfulTheme::default(); + let scope_items = [ + "Repository local (current project)", + "Global (all projects)", + ]; + let scope = match Select::with_theme(&theme) + .with_prompt("Install scope") + .items(&scope_items) + .default(0) + .interact_opt() + .map_err(|error| format!("could not read install scope: {error}"))? + { + Some(0) => InstallScope::Repository, + Some(1) => InstallScope::Global, + Some(_) => unreachable!("scope picker returned an unknown item"), + None => return cancelled(), + }; + + let directory_items = [".agents (GitHub Copilot / Codex)", ".claude (Claude Code)"]; + let directory = match Select::with_theme(&theme) + .with_prompt("Skills directory") + .items(&directory_items) + .default(0) + .interact_opt() + .map_err(|error| format!("could not read skills directory: {error}"))? + { + Some(0) => AgentDirectory::Agents, + Some(1) => AgentDirectory::Claude, + Some(_) => unreachable!("directory picker returned an unknown item"), + None => return cancelled(), + }; + + let current_dir = std::env::current_dir() + .map_err(|error| format!("could not resolve current directory: {error}"))?; + let home = dirs::home_dir(); + let path = install_path(scope, directory, ¤t_dir, home.as_deref())?; + write_manifest(&path, manifest)?; + println!("Installed {SKILL_NAME} skill at {}", path.display()); + Ok(()) +} + +fn cancelled() -> Result<(), String> { + println!("Skill installation cancelled."); + Ok(()) +} + +fn install_path( + scope: InstallScope, + directory: AgentDirectory, + current_dir: &Path, + home: Option<&Path>, +) -> Result { + let root = match scope { + InstallScope::Repository => current_dir, + InstallScope::Global => home.ok_or_else(|| { + "could not resolve the home directory for a global installation".to_string() + })?, + }; + Ok(root + .join(directory.name()) + .join("skills") + .join(SKILL_NAME) + .join("SKILL.md")) +} + +fn write_manifest(path: &Path, manifest: &str) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("invalid skill path: {}", path.display()))?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("could not create {}: {error}", parent.display()))?; + std::fs::write(path, manifest) + .map_err(|error| format!("could not write {}: {error}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_dir(tag: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!( + "shell-use-skill-{tag}-{}-{nanos}", + std::process::id() + )) + } + + #[test] + fn repository_agents_path_is_under_the_current_directory() { + let current_dir = Path::new("repo"); + let path = install_path( + InstallScope::Repository, + AgentDirectory::Agents, + current_dir, + None, + ) + .expect("repository path"); + assert_eq!( + path, + current_dir + .join(".agents") + .join("skills") + .join("shell-use") + .join("SKILL.md") + ); + } + + #[test] + fn global_claude_path_is_under_the_home_directory() { + let home = Path::new("home").join("tester"); + let path = install_path( + InstallScope::Global, + AgentDirectory::Claude, + Path::new("repo"), + Some(&home), + ) + .expect("global path"); + assert_eq!( + path, + home.join(".claude") + .join("skills") + .join("shell-use") + .join("SKILL.md") + ); + } + + #[test] + fn global_install_requires_a_home_directory() { + let error = install_path( + InstallScope::Global, + AgentDirectory::Agents, + Path::new("repo"), + None, + ) + .expect_err("missing home must fail"); + assert!(error.contains("home directory")); + } + + #[test] + fn writing_manifest_creates_directories_and_replaces_existing_content() { + let root = unique_dir("write"); + let path = root + .join(".agents") + .join("skills") + .join("shell-use") + .join("SKILL.md"); + write_manifest(&path, "old").expect("initial write"); + write_manifest(&path, "new").expect("replacement write"); + assert_eq!(std::fs::read_to_string(&path).expect("read skill"), "new"); + std::fs::remove_dir_all(root).expect("remove temp directory"); + } +}