feat: load commands from installed Claude Code plugins#494
feat: load commands from installed Claude Code plugins#494eric-wang-1990 wants to merge 5 commits intositeboon:mainfrom
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughServer adds plugin-based command discovery by reading ~/.claude/plugins/installed_plugins.json, scanning each plugin's commandsDir (namespaced as plugin:), merging plugin commands into /list with deduplication and priority ordering, and tightening realpath-based path checks and error handling for loading/execution. WebSocketContext gets minor lifecycle/formatting tweaks. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client
participant Server as Server (/api/commands/list)
participant FS as Filesystem
Client->>Server: POST /api/commands/list
Server->>FS: read ~/.claude/plugins/installed_plugins.json
alt plugins file absent
note right of FS: handled silently
else plugins found
FS-->>Server: installed plugin entries (name + commandsDir)
loop for each plugin
Server->>FS: scan plugin commandsDir (scanCommandsDirectory)
FS-->>Server: plugin command files/metadata
end
end
Server->>Server: namespace plugin commands as plugin:<name>, merge with builtin/project/user, dedupe (first-seen precedence)
Server-->>Client: aggregated command list
Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs). Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Scan ~/.claude/plugins/installed_plugins.json to discover commands provided by marketplace plugins (e.g. spec-driven-workflow, pr-review-toolkit). Each plugin's commands/ directory is scanned and commands are exposed with a 'plugin:<name>' namespace alongside existing user and project commands. Also extends the /execute security check to allow commandPath values under ~/.claude/plugins/cache/, so plugin commands can be loaded and run.
7d2033b to
8fc6a81
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@server/routes/commands.js`:
- Around line 556-565: The current guard in the /load handler (the
resolvedPath.startsWith + includes check) is too permissive and vulnerable to
path traversal; replace it with a boundary-safe check that resolves real paths
and validates the file is inside one of explicit command roots (e.g., the app
commands directory and any plugin command roots). Use fs.realpath to
canonicalize both the requested path and each allowed root, then use
path.relative to ensure the relative path does not start with '..' (and is not
equal to '') before allowing load; update the check around resolvedPath (and the
code that references commandPath) to reject access unless the request is inside
one of those explicit roots and ensure plugin roots are included in that
allowed-roots list.
- Around line 516-531: The API currently returns commands where duplicate names
across sources remain ambiguous; update the response from the handler that
builds allCommands/pluginCommands/customCommands to either enforce unique names
or provide a deterministic qualifier: scan allCommands (produced by
scanPluginCommands and the builtins), detect duplicates by cmd.name, and for
duplicates add a deterministic disambiguator property (e.g., cmd.source or
cmd.qualifiedName including namespace or a precedence flag) or normalize names
by prefixing with namespace per your chosen precedence (e.g., prefer builtin
over plugin). Ensure builtIn: builtInCommands and custom: customCommands include
that new property so the client (which uses slashCommands.find((cmd) => cmd.name
=== commandName)) can unambiguously resolve commands.
- Around line 645-659: The path guard currently allows any file under
pluginCacheBase (~/.claude/plugins/cache) via the isUnder check, which is too
broad; change the plugin allowance to test resolvedPath against each discovered
plugin commands root (the per-plugin commandsDir entries) instead of
pluginCacheBase. Update the if-condition that uses isUnder(pluginCacheBase) to
iterate the list of commandsDir roots (the array returned/used when discovering
plugin commands) and allow the request only if isUnder(commandsDir) is true for
at least one entry; keep the existing checks for userBase and projectBase and
continue to use isUnder, resolvedPath, and projectBase as before. Ensure the
variable you iterate is the same one that holds the discovered plugin commands
directories so only plugin-specific commands/markdown files are permitted.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: c3f63e08-c0f4-427e-aa08-541a2a39df73
📒 Files selected for processing (1)
server/routes/commands.js
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
server/routes/commands.js (1)
600-617:⚠️ Potential issue | 🔴 CriticalUse
fs.realpath()to canonicalize paths before the boundary check.
path.resolve()normalizes path segments lexically but does not dereference symlinks. An attacker can place a symlink in an allowed command directory that points to a file outside that directory, andfs.readFile(commandPath)will follow it. Canonicalize the requested file and each allowed base directory withfs.realpath()before comparing:- const resolvedPath = path.resolve(commandPath); - const userBase = path.resolve(path.join(os.homedir(), '.claude', 'commands')); - const pluginCacheBase = path.resolve(path.join(os.homedir(), '.claude', 'plugins', 'cache')); + const resolvedPath = await fs.realpath(commandPath); + const userBase = await fs.realpath(path.join(os.homedir(), '.claude', 'commands')).catch(() => null); + const pluginCacheBase = await fs.realpath(path.join(os.homedir(), '.claude', 'plugins', 'cache')).catch(() => null); const projectBase = context?.projectPath - ? path.resolve(path.join(context.projectPath, '.claude', 'commands')) + ? await fs.realpath(path.join(context.projectPath, '.claude', 'commands')).catch(() => null) : null; const isUnder = (base) => { + if (!base) return false; const rel = path.relative(base, resolvedPath); return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/routes/commands.js` around lines 600 - 617, The path check uses path.resolve but doesn't dereference symlinks, allowing a symlink escape; call fs.realpath() (or fs.promises.realpath) on commandPath and on each base (userBase, pluginCacheBase, projectBase) before running the isUnder check, then update isUnder to compare the canonical real paths (e.g., realResolvedPath vs realBase); ensure you await realpath calls in a try/catch and return an appropriate 403/404 if realpath fails or the canonicalized file is not under any canonicalized base before calling fs.readFile(commandPath).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@server/routes/commands.js`:
- Around line 610-613: The 403 response message in the conditional that checks
isUnder(userBase) || isUnder(pluginCacheBase) || (projectBase &&
isUnder(projectBase)) is outdated; update the JSON error message returned in
that block to reflect the new allowlist (user command dir, plugin cache roots,
or project command root) instead of only ".claude/commands directory" so callers
see accurate access-denied text.
---
Outside diff comments:
In `@server/routes/commands.js`:
- Around line 600-617: The path check uses path.resolve but doesn't dereference
symlinks, allowing a symlink escape; call fs.realpath() (or
fs.promises.realpath) on commandPath and on each base (userBase,
pluginCacheBase, projectBase) before running the isUnder check, then update
isUnder to compare the canonical real paths (e.g., realResolvedPath vs
realBase); ensure you await realpath calls in a try/catch and return an
appropriate 403/404 if realpath fails or the canonicalized file is not under any
canonicalized base before calling fs.readFile(commandPath).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: b053b59f-322d-414e-a79c-596087e90f5c
📒 Files selected for processing (1)
server/routes/commands.js
…s routes - Add command deduplication with precedence: builtin > project > user > plugin - Fix /load endpoint to use fs.realpath() to prevent symlink-based path traversal - Fix /execute endpoint to validate against actual plugin install dirs instead of plugin cache base Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
server/routes/commands.js (1)
101-113: Add defensive checks for JSON structure.The code assumes
pluginsexists and each entry is an array with objects containinginstallPath. If the JSON is malformed or has an unexpected structure, this will throw uncaught errors.🛡️ Proposed defensive fix
try { const raw = await fs.readFile(pluginsJsonPath, "utf8"); - const { plugins } = JSON.parse(raw); + const parsed = JSON.parse(raw); + const plugins = parsed?.plugins; + + if (!plugins || typeof plugins !== "object") { + return dirs; + } for (const [pluginKey, installs] of Object.entries(plugins)) { + if (!Array.isArray(installs)) continue; const pluginName = pluginKey.split("@")[0]; for (const install of installs) { + if (!install?.installPath) continue; dirs.push({ pluginName, commandsDir: path.join(install.installPath, "commands"), }); } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/routes/commands.js` around lines 101 - 113, The code reading and iterating the parsed JSON assumes a well-formed structure (variables: raw, plugins from JSON.parse(raw), pluginKey, installs, install.installPath, dirs, pluginsJsonPath); add defensive checks after parsing to verify that plugins is an object, that each installs is an array, and that each install has a non-empty string installPath before calling path.join and pushing to dirs; skip invalid entries (optionally log a warning) rather than throwing, and ensure the loop continues safely if the JSON shape is unexpected.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@server/routes/commands.js`:
- Around line 676-707: Replace the current path.resolve-based symlink check in
the /execute handler by resolving the actual filesystem path for the target and
bases using fs.realpath: call resolvedPath = await fs.realpath(commandPath)
(instead of path.resolve(commandPath)), compute realpaths for userBase, each
pluginBases entry and projectBase (use await fs.realpath(...) for each base) and
then run the existing isUnder checks against those real paths; finally, read the
file from the resolvedPath (fs.readFile(resolvedPath, "utf8")) instead of the
original commandPath so symlinked files cannot bypass the allowed-directory
validation.
---
Nitpick comments:
In `@server/routes/commands.js`:
- Around line 101-113: The code reading and iterating the parsed JSON assumes a
well-formed structure (variables: raw, plugins from JSON.parse(raw), pluginKey,
installs, install.installPath, dirs, pluginsJsonPath); add defensive checks
after parsing to verify that plugins is an object, that each installs is an
array, and that each install has a non-empty string installPath before calling
path.join and pushing to dirs; skip invalid entries (optionally log a warning)
rather than throwing, and ensure the loop continues safely if the JSON shape is
unexpected.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 43e405d7-b843-4b9e-a95f-8c1b86d9c66e
📒 Files selected for processing (1)
server/routes/commands.js
…er login The effect cleanup sets unmountedRef=true, which also fires when token changes (not just on unmount). This caused connect() to bail out immediately after login on fresh installs where token wasn't in localStorage. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/contexts/WebSocketContext.tsx (1)
45-57: 🛠️ Refactor suggestion | 🟠 MajorDistinguish intentional closes from real disconnects.
On a
tokenchange, the previous cleanup closes the old socket, but the next effect immediately flipsunmountedRef.currentback tofalse. If that old socket’soncloseruns afterward, it schedules another reconnect and you can end up with parallel sockets / duplicate messages. This also misses sockets still inCONNECTING, becausewsRef.currentis only assigned inonopen.Suggested fix
useEffect(() => { unmountedRef.current = false; connect(); return () => { unmountedRef.current = true; if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; } if (wsRef.current) { + wsRef.current.onclose = null; // intentional close: do not reconnect wsRef.current.close(); } }; }, [token]); const connect = useCallback(() => { if (unmountedRef.current) return; try { const wsUrl = buildWebSocketUrl(token); if (!wsUrl) { return console.warn( "No authentication token found for WebSocket connection", ); } const websocket = new WebSocket(wsUrl); + wsRef.current = websocket; // track CONNECTING sockets too websocket.onopen = () => { + if (wsRef.current !== websocket || unmountedRef.current) { + websocket.close(); + return; + } setIsConnected(true); - wsRef.current = websocket; }; websocket.onclose = () => { - setIsConnected(false); - wsRef.current = null; + if (wsRef.current === websocket) { + wsRef.current = null; + setIsConnected(false); + } + + if (unmountedRef.current) return; reconnectTimeoutRef.current = setTimeout(() => { if (unmountedRef.current) return; connect(); }, 3000); };Also applies to: 71-76, 87-95
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/contexts/WebSocketContext.tsx` around lines 45 - 57, The cleanup on token change is closing the old socket but not distinguishing intentional closes from real disconnects, and wsRef.current is only set in onopen so connections in CONNECTING can be missed; modify the logic to add an "intentionalClose" flag (e.g., intentionalCloseRef) that is set true in the cleanup before calling wsRef.current.close() and set false when creating a new connection, assign wsRef.current to the new WebSocket immediately inside connect (before attaching handlers) so CONNECTING sockets are tracked, and update the onclose handler to check intentionalCloseRef.current and only schedule reconnects when it is false; also ensure you clear/close any existing wsRef.current before creating a new one to avoid parallel sockets.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@src/contexts/WebSocketContext.tsx`:
- Around line 45-57: The cleanup on token change is closing the old socket but
not distinguishing intentional closes from real disconnects, and wsRef.current
is only set in onopen so connections in CONNECTING can be missed; modify the
logic to add an "intentionalClose" flag (e.g., intentionalCloseRef) that is set
true in the cleanup before calling wsRef.current.close() and set false when
creating a new connection, assign wsRef.current to the new WebSocket immediately
inside connect (before attaching handlers) so CONNECTING sockets are tracked,
and update the onclose handler to check intentionalCloseRef.current and only
schedule reconnects when it is false; also ensure you clear/close any existing
wsRef.current before creating a new one to avoid parallel sockets.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: ab95113e-72ef-4787-a4c2-14e146f257bb
📒 Files selected for processing (1)
src/contexts/WebSocketContext.tsx
Align /execute's path validation with /load: canonicalize commandPath via fs.realpath() so symlinks within an allowed directory cannot escape to files outside the allowed roots. Also read the file from the canonicalized resolvedPath instead of the raw commandPath. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
server/routes/commands.js (1)
744-746: Consider hardening@filenameinclude resolution in command parser.The command file path is now validated with
fs.realpath(), but when the command content is processed,@filenameincludes are resolved usingpath.resolve()only (seeprocessFileIncludesinserver/utils/commandParser.js). A symlink within the allowed directory could still be used to include files outside the intended scope when the command content is later processed.Consider applying the same
fs.realpath()+ boundary check to file includes for consistent security guarantees.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/routes/commands.js` around lines 744 - 746, The processFileIncludes logic currently resolves `@filename` includes with path.resolve() which can be bypassed via symlinks; update processFileIncludes in server/utils/commandParser.js to call fs.realpath() (or fs.promises.realpath) on the resolved path and then re-run the existing boundary check used elsewhere (the same allowed-directory compare used when validating the original command file) before reading the included file, throwing or skipping the include if realpath is outside the allowed base; ensure this change covers all code paths that handle "@" includes so include resolution is hardened consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@server/routes/commands.js`:
- Around line 101-113: Add a defensive validation before iterating over the
parsed `plugins` value read from `pluginsJsonPath`: after `const { plugins } =
JSON.parse(raw);` check that `plugins` is a non-null object (e.g., typeof
plugins === "object" && plugins !== null && !Array.isArray(plugins)); if the
check fails, log a warning and skip the loop (do not call Object.entries),
ensuring `dirs` remains unchanged; apply this check around the loop that builds
`dirs` from `plugins` to avoid TypeError during malformed
`installed_plugins.json` files.
- Around line 579-583: The /load handler's allowedRoots currently includes only
the user and plugin command directories but not a project-level commands dir, so
update the handler to read projectPath from the request body (like /list does)
and, if present, push path.join(projectPath, ".claude", "commands") into the
allowedRoots array before performing the access check; modify the code near
where allowedRoots is built (the variable named allowedRoots and where
getInstalledPluginCommandDirs() is iterated) to include this project path so
project-level .claude/commands are accepted.
- Around line 107-112: The code assumes installs is iterable and will throw if
it's null or not an array; before the for (const install of installs) loop in
server/routes/commands.js, add a guard that verifies installs is an Array (e.g.,
Array.isArray(installs)) and skip processing this plugin entry if not; ensure
you still push valid { pluginName, commandsDir: path.join(install.installPath,
"commands") } entries only when installs is an array and each install has a
valid install.installPath string to avoid further runtime errors.
---
Nitpick comments:
In `@server/routes/commands.js`:
- Around line 744-746: The processFileIncludes logic currently resolves
`@filename` includes with path.resolve() which can be bypassed via symlinks;
update processFileIncludes in server/utils/commandParser.js to call
fs.realpath() (or fs.promises.realpath) on the resolved path and then re-run the
existing boundary check used elsewhere (the same allowed-directory compare used
when validating the original command file) before reading the included file,
throwing or skipping the include if realpath is outside the allowed base; ensure
this change covers all code paths that handle "@" includes so include resolution
is hardened consistently.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 575ce106-95e8-49f6-aff3-e9aac2c103d0
📒 Files selected for processing (1)
server/routes/commands.js
| try { | ||
| const raw = await fs.readFile(pluginsJsonPath, "utf8"); | ||
| const { plugins } = JSON.parse(raw); | ||
|
|
||
| for (const [pluginKey, installs] of Object.entries(plugins)) { | ||
| const pluginName = pluginKey.split("@")[0]; | ||
| for (const install of installs) { | ||
| dirs.push({ | ||
| pluginName, | ||
| commandsDir: path.join(install.installPath, "commands"), | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Add defensive check for plugins object before iteration.
If installed_plugins.json exists but is malformed (e.g., missing plugins key, or plugins is not an object), Object.entries(plugins) on line 105 will throw a TypeError. Consider validating before iteration.
🛡️ Proposed defensive check
const raw = await fs.readFile(pluginsJsonPath, "utf8");
const { plugins } = JSON.parse(raw);
+ if (!plugins || typeof plugins !== "object") {
+ return dirs;
+ }
+
for (const [pluginKey, installs] of Object.entries(plugins)) {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/routes/commands.js` around lines 101 - 113, Add a defensive validation
before iterating over the parsed `plugins` value read from `pluginsJsonPath`:
after `const { plugins } = JSON.parse(raw);` check that `plugins` is a non-null
object (e.g., typeof plugins === "object" && plugins !== null &&
!Array.isArray(plugins)); if the check fails, log a warning and skip the loop
(do not call Object.entries), ensuring `dirs` remains unchanged; apply this
check around the loop that builds `dirs` from `plugins` to avoid TypeError
during malformed `installed_plugins.json` files.
| for (const install of installs) { | ||
| dirs.push({ | ||
| pluginName, | ||
| commandsDir: path.join(install.installPath, "commands"), | ||
| }); | ||
| } |
There was a problem hiding this comment.
Validate installs is an array before iteration.
If a plugin entry in installed_plugins.json has an unexpected structure (e.g., installs is null or not an array), the for...of loop will throw. Add a guard to skip malformed entries.
🛡️ Proposed fix
for (const [pluginKey, installs] of Object.entries(plugins)) {
const pluginName = pluginKey.split("@")[0];
+ if (!Array.isArray(installs)) continue;
for (const install of installs) {
+ if (!install?.installPath) continue;
dirs.push({
pluginName,
commandsDir: path.join(install.installPath, "commands"),
});
}
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/routes/commands.js` around lines 107 - 112, The code assumes installs
is iterable and will throw if it's null or not an array; before the for (const
install of installs) loop in server/routes/commands.js, add a guard that
verifies installs is an Array (e.g., Array.isArray(installs)) and skip
processing this plugin entry if not; ensure you still push valid { pluginName,
commandsDir: path.join(install.installPath, "commands") } entries only when
installs is an array and each install has a valid install.installPath string to
avoid further runtime errors.
| const allowedRoots = [path.join(os.homedir(), ".claude", "commands")]; | ||
| const pluginDirs = await getInstalledPluginCommandDirs(); | ||
| for (const { commandsDir } of pluginDirs) { | ||
| allowedRoots.push(commandsDir); | ||
| } |
There was a problem hiding this comment.
Missing project commands directory in /load allowedRoots.
The /execute endpoint includes context?.projectPath in its allowed roots (lines 695-699), but /load only includes user and plugin directories. This means project-level commands (from .claude/commands/ within a project) will fail the access check with a 403 error when loaded.
The /load endpoint should accept projectPath in the request body (similar to /list) and include it in allowedRoots.
🔧 Proposed fix
router.post("/load", async (req, res) => {
try {
- const { commandPath } = req.body;
+ const { commandPath, projectPath } = req.body;
if (!commandPath) {
return res.status(400).json({
error: "Command path is required",
});
}
// ... realpath validation ...
const allowedRoots = [path.join(os.homedir(), ".claude", "commands")];
const pluginDirs = await getInstalledPluginCommandDirs();
for (const { commandsDir } of pluginDirs) {
allowedRoots.push(commandsDir);
}
+ if (projectPath) {
+ allowedRoots.push(path.join(projectPath, ".claude", "commands"));
+ }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/routes/commands.js` around lines 579 - 583, The /load handler's
allowedRoots currently includes only the user and plugin command directories but
not a project-level commands dir, so update the handler to read projectPath from
the request body (like /list does) and, if present, push path.join(projectPath,
".claude", "commands") into the allowedRoots array before performing the access
check; modify the code near where allowedRoots is built (the variable named
allowedRoots and where getInstalledPluginCommandDirs() is iterated) to include
this project path so project-level .claude/commands are accepted.
|
@eric-wang-1990 Claude agent sdk has a function to look up skills from here https://platform.claude.com/docs/en/agent-sdk/skills can you check if you can make use of this ? Moreover if I know just ask Claude to use a specific skill it will do so. A / command is not needed I believe. |
Problem
Users who install marketplace plugins via Claude Code (e.g.
spec-driven-workflow,pr-review-toolkit,everything-claude-code) cannot access those plugin commands from the slash command picker in the UI.Plugin commands are installed to
~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/commands/but the app only scans~/.claude/commands/and.claude/commands/.Solution
Read
~/.claude/plugins/installed_plugins.jsonto discover all installed plugins, then scan each plugin'scommands/directory using the existingscanCommandsDirectoryfunction. Plugin commands are exposed with aplugin:<name>namespace (e.g.plugin:spec-driven-workflow).Also extends the
/executeendpoint's security check to allowcommandPathvalues under~/.claude/plugins/cache/, so plugin commands can actually be loaded and run.Changes
server/routes/commands.js: addgetInstalledPluginCommandDirs()andscanPluginCommands()functions, callscanPluginCommands()in thePOST /listroute, and extend the path-traversal guard inPOST /executeto include the plugin cache directory.Testing
With marketplace plugins installed, the
/api/commands/listendpoint now returns them alongside built-in and user commands:These appear in the slash command picker in the UI as expected.
Notes
installed_plugins.json(no plugins installed = no-op)scanCommandsDirectory— no new scanning logicSummary by CodeRabbit
New Features
Bug Fixes
Chores
Style