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
change kmpomxyqyqxzqtoxyqulsrorqkynlqls
commit 5faff27cd2f924bdd26cdd4594db7282f3e0697e
author Alpha Chen <alpha@kejadlen.dev>
date
parent vyuyvlml
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()