feat: Add support for commands
Plugins can now contain a commands/ directory alongside (or instead of)
skills/. Entries are symlinked into ~/.claude/commands/ and cleaned up
the same way skills are.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/AGENTS.md b/AGENTS.md
index 108a7c8..283f2ef 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,7 +20,7 @@ This runs `cargo fmt`, `cargo check`, and `cargo clippy`. Always run `just` befo
See README.md for the full design. Key points:
-- Only skills are supported. Fail loudly for anything else.
+- Only skills and commands are supported. Fail loudly for anything else.
- `update` reads config, resolves git refs to SHAs, writes lockfile. Does not touch symlinks.
- `install` reads lockfile, checks out repos, creates symlinks in ~/.claude/skills/. Does not read config.
- Cleanup removes symlinks pointing into ~/.cache/pinch/ that aren't in the lockfile.
diff --git a/README.md b/README.md
index 0031ce0..3778a7f 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,24 @@
# pinch
A plugin manager for Claude Code (and [pi](https://github.com/nichochar/pi-coding-agent),
-which reads from the same `~/.claude/skills/` directory). Installs skills as
-symlinks. Rust, single binary, keep it simple.
+which reads from the same `~/.claude/skills/` directory). Installs skills and
+commands as symlinks. Rust, single binary, keep it simple.
-Only skills are supported for now. Pinch will fail loudly if asked to manage
-anything else.
+Only skills and commands are supported for now. Pinch will fail loudly if asked
+to manage anything else.
## Concepts
A **skill** is a directory containing a `SKILL.md` file (and optionally other
files). Claude and pi discover skills by scanning `~/.claude/skills/<name>/`.
+A **command** is a slash command — an `.md` file (or directory) that Claude Code
+discovers by scanning `~/.claude/commands/`.
+
A **marketplace** is a git repo that contains one or more plugins, described by
a `.claude-plugin/marketplace.json` manifest.
-A **package** is a git repo that contains one or more skills. The repo is cloned
+A **package** is a git repo that contains one or more skills and/or commands. The repo is cloned
(bare) into pinch's cache; worktrees are created per revision so multiple
versions can coexist. Individual skills are symlinked into `~/.claude/skills/`.
@@ -41,6 +44,9 @@ versions can coexist. Individual skills are symlinked into `~/.claude/skills/`.
~/.claude/skills/
jj -> ~/.cache/pinch/repos/github.com/user/repo.git/pinch-worktrees/abc123def456/plugins/jj/skills/jj
gh-pr -> ~/.cache/pinch/repos/github.com/user/repo.git/pinch-worktrees/def789012345/plugins/gh-pr/skills/gh-pr
+
+~/.claude/commands/
+ deploy.md -> ~/.cache/pinch/repos/github.com/user/repo.git/pinch-worktrees/abc123def456/plugins/ops/commands/deploy.md
```
## Config (manifest)
@@ -97,10 +103,10 @@ plugins:
Reads the **lockfile** (not the config). For each entry:
1. Ensures the repo is cloned/fetched and checked out at the locked `rev`.
-2. Creates a symlink from `~/.claude/skills/<name>` → the skill's path in the
- cached repo.
-3. Removes any symlinks in `~/.claude/skills/` that point into
- `~/.cache/pinch/` but are no longer in the lockfile.
+2. Creates symlinks from `~/.claude/skills/<name>` and `~/.claude/commands/<name>`
+ → the corresponding paths in the cached repo.
+3. Removes any symlinks in `~/.claude/skills/` and `~/.claude/commands/` that
+ point into `~/.cache/pinch/` but are no longer in the lockfile.
Idempotent. Safe to run repeatedly.
@@ -131,6 +137,6 @@ decisions, and review; the AI wrote the code, tests, and docs.
## Non-goals (for now)
- Dependencies between packages
-- Non-skill plugin types (extensions, themes, custom tools)
+- Other plugin types (extensions, themes, custom tools)
- A registry / central index
- Running in project-scoped mode (everything is user-global)
diff --git a/src/main.rs b/src/main.rs
index 54135ee..8824aa0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -152,8 +152,10 @@ fn do_install() -> Result<()> {
let repos_dir = paths::repos_dir()?;
let skills_dir = paths::skills_dir()?;
+ let commands_dir = paths::commands_dir()?;
let cache_dir = paths::cache_dir();
let mut installed_skills = std::collections::HashSet::new();
+ let mut installed_commands = std::collections::HashSet::new();
for (name, plugin) in &lockfile.plugins {
let repo_path = git::repo_path(&repos_dir, &plugin.src);
@@ -170,10 +172,10 @@ fn do_install() -> Result<()> {
}
// Reject unsupported plugin types
- for unsupported in ["commands", "agents", "hooks"] {
+ for unsupported in ["agents", "hooks"] {
if plugin_root.join(unsupported).is_dir() {
bail!(
- "plugin '{}': contains '{}/' — only skills are supported",
+ "plugin '{}': contains '{}/' — only skills and commands are supported",
name,
unsupported
);
@@ -182,7 +184,7 @@ fn do_install() -> Result<()> {
for unsupported in [".mcp.json", ".lsp.json"] {
if plugin_root.join(unsupported).is_file() {
bail!(
- "plugin '{}': contains '{}' — only skills are supported",
+ "plugin '{}': contains '{}' — only skills and commands are supported",
name,
unsupported
);
@@ -191,50 +193,103 @@ fn do_install() -> Result<()> {
// Symlink each skill under skills/
let skills_source = plugin_root.join("skills");
- if !skills_source.is_dir() {
+ if skills_source.is_dir() {
+ for entry in fs_err::read_dir(&skills_source)? {
+ let entry = entry?;
+ if !entry.path().is_dir() {
+ continue;
+ }
+
+ let skill_name = entry.file_name();
+ let symlink_path = skills_dir.join(&skill_name);
+
+ // Remove existing symlink if present
+ if symlink_path.symlink_metadata().is_ok() {
+ fs_err::remove_file(&symlink_path)?;
+ }
+
+ std::os::unix::fs::symlink(entry.path(), &symlink_path).with_context(|| {
+ format!(
+ "failed to create symlink: {} -> {}",
+ symlink_path.display(),
+ entry.path().display()
+ )
+ })?;
+
+ info!(
+ "installed skill {} -> {}",
+ skill_name.to_string_lossy(),
+ entry.path().display()
+ );
+ installed_skills.insert(skill_name);
+ }
+ }
+
+ // Symlink each command under commands/
+ let commands_source = plugin_root.join("commands");
+ if commands_source.is_dir() {
+ for entry in fs_err::read_dir(&commands_source)? {
+ let entry = entry?;
+ let command_name = entry.file_name();
+ let symlink_path = commands_dir.join(&command_name);
+
+ // Remove existing symlink if present
+ if symlink_path.symlink_metadata().is_ok() {
+ fs_err::remove_file(&symlink_path)?;
+ }
+
+ std::os::unix::fs::symlink(entry.path(), &symlink_path).with_context(|| {
+ format!(
+ "failed to create symlink: {} -> {}",
+ symlink_path.display(),
+ entry.path().display()
+ )
+ })?;
+
+ info!(
+ "installed command {} -> {}",
+ command_name.to_string_lossy(),
+ entry.path().display()
+ );
+ installed_commands.insert(command_name);
+ }
+ }
+
+ if !skills_source.is_dir() && !commands_source.is_dir() {
bail!(
- "plugin '{}': no skills/ directory in '{}'",
+ "plugin '{}': no skills/ or commands/ directory in '{}'",
name,
plugin.path
);
}
+ }
- for entry in fs_err::read_dir(&skills_source)? {
- let entry = entry?;
- if !entry.path().is_dir() {
- continue;
- }
-
- let skill_name = entry.file_name();
- let symlink_path = skills_dir.join(&skill_name);
+ // Clean up stale skill symlinks
+ for entry in fs_err::read_dir(&skills_dir)? {
+ let entry = entry?;
+ let path = entry.path();
- // Remove existing symlink if present
- if symlink_path.symlink_metadata().is_ok() {
- fs_err::remove_file(&symlink_path)?;
- }
+ if !path.symlink_metadata()?.file_type().is_symlink() {
+ continue;
+ }
- std::os::unix::fs::symlink(entry.path(), &symlink_path).with_context(|| {
- format!(
- "failed to create symlink: {} -> {}",
- symlink_path.display(),
- entry.path().display()
- )
- })?;
+ let name = entry.file_name();
+ let name_str = name.to_string_lossy();
- info!(
- "installed {} -> {}",
- skill_name.to_string_lossy(),
- entry.path().display()
- );
- installed_skills.insert(skill_name);
+ if let Ok(target) = fs_err::read_link(&path)
+ && target.starts_with(&cache_dir)
+ && !installed_skills.contains(&name)
+ {
+ fs_err::remove_file(&path)?;
+ info!("removed stale skill symlink: {}", name_str);
}
}
- for entry in fs_err::read_dir(&skills_dir)? {
+ // Clean up stale command symlinks
+ for entry in fs_err::read_dir(&commands_dir)? {
let entry = entry?;
let path = entry.path();
- // Only consider symlinks
if !path.symlink_metadata()?.file_type().is_symlink() {
continue;
}
@@ -242,13 +297,12 @@ fn do_install() -> Result<()> {
let name = entry.file_name();
let name_str = name.to_string_lossy();
- // Only touch symlinks pointing into our cache
if let Ok(target) = fs_err::read_link(&path)
&& target.starts_with(&cache_dir)
- && !installed_skills.contains(&name)
+ && !installed_commands.contains(&name)
{
fs_err::remove_file(&path)?;
- info!("removed stale symlink: {}", name_str);
+ info!("removed stale command symlink: {}", name_str);
}
}
diff --git a/src/paths.rs b/src/paths.rs
index a800c34..bcf38ca 100644
--- a/src/paths.rs
+++ b/src/paths.rs
@@ -32,6 +32,12 @@ pub fn skills_dir() -> Result<PathBuf> {
Ok(path)
}
+pub fn commands_dir() -> Result<PathBuf> {
+ let path = home_dir().join(".claude").join("commands");
+ fs_err::create_dir_all(&path)?;
+ Ok(path)
+}
+
pub fn cache_dir() -> PathBuf {
base_dirs()
.get_cache_home()