Skip to content

feat: load commands from installed Claude Code plugins#494

Open
eric-wang-1990 wants to merge 5 commits intositeboon:mainfrom
eric-wang-1990:feat/plugin-commands-support
Open

feat: load commands from installed Claude Code plugins#494
eric-wang-1990 wants to merge 5 commits intositeboon:mainfrom
eric-wang-1990:feat/plugin-commands-support

Conversation

@eric-wang-1990
Copy link

@eric-wang-1990 eric-wang-1990 commented Mar 6, 2026

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.json to discover all installed plugins, then scan each plugin's commands/ directory using the existing scanCommandsDirectory function. Plugin commands are exposed with a plugin:<name> namespace (e.g. plugin:spec-driven-workflow).

Also extends the /execute endpoint's security check to allow commandPath values under ~/.claude/plugins/cache/, so plugin commands can actually be loaded and run.

Changes

  • server/routes/commands.js: add getInstalledPluginCommandDirs() and scanPluginCommands() functions, call scanPluginCommands() in the POST /list route, and extend the path-traversal guard in POST /execute to include the plugin cache directory.

Testing

With marketplace plugins installed, the /api/commands/list endpoint now returns them alongside built-in and user commands:

/AddressPR          [plugin:spec-driven-workflow]
/WorkOn             [plugin:spec-driven-workflow]
/review-pr          [plugin:pr-review-toolkit]
/tdd                [plugin:everything-claude-code]
...

These appear in the slash command picker in the UI as expected.

Notes

  • Gracefully handles missing installed_plugins.json (no plugins installed = no-op)
  • Reuses existing scanCommandsDirectory — no new scanning logic
  • Zero impact if the user has no plugins installed

Summary by CodeRabbit

  • New Features

    • Installed plugins' commands are now discovered and included in command listings.
  • Bug Fixes

    • Command listing deduplicates and prioritizes built-in > project > user > plugin to avoid collisions.
    • Path resolution and permission checks strengthened (realpath-based containment, plugin dirs allowed) for safer loading/execution.
    • Error messages and logging standardized for clearer feedback.
  • Chores

    • Missing plugin manifest is silently ignored; other read errors are logged.
  • Style

    • Formatting and string-style consistency improvements; provider lifecycle flag initialization fixed.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 6, 2026

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Server 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

Cohort / File(s) Summary
Plugin discovery & command aggregation
server/routes/commands.js
Added getInstalledPluginCommandDirs() to read ~/.claude/plugins/installed_plugins.json (skip if absent), scanPluginCommands() to scan plugin commandsDir and namespace as plugin:<name>, and merge plugin commands into /api/commands/list with deduplication and precedence (builtin > project > user > plugin).
Path handling & execution security
server/routes/commands.js
Switched to realpath-based resolution for loading/executing commands, expanded allowedRoots to include plugin command directories, and tightened path containment checks to prevent traversal.
Command loading/execution & error messaging
server/routes/commands.js
Standardized error response keys/format (error, message), unified logging, and improved error handling for listing, loading, and executing commands.
Styling & minor behavior
server/routes/commands.js, src/contexts/WebSocketContext.tsx
Normalized string quoting and formatting; WebSocketContext explicitly initializes unmountedRef.current = false in provider effect and has small logging/lifecycle adjustments without changing public API.

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
Loading

Possibly related PRs

Suggested reviewers

  • viper151

Poem

🐇 I sniff the plugin trail tonight,
Commands in folders, hidden from sight,
Namespaced neat as plugin: so bright,
Realpaths guarded, no paths take flight,
I hop and list them — what a sight!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: load commands from installed Claude Code plugins' directly and accurately describes the main change: adding plugin command discovery and loading from installed Claude Code plugins in ~/.claude/plugins/
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

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.
@eric-wang-1990 eric-wang-1990 force-pushed the feat/plugin-commands-support branch from 7d2033b to 8fc6a81 Compare March 6, 2026 07:44
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between d299ab8 and 7d2033b.

📒 Files selected for processing (1)
  • server/routes/commands.js

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🔴 Critical

Use 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, and fs.readFile(commandPath) will follow it. Canonicalize the requested file and each allowed base directory with fs.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

📥 Commits

Reviewing files that changed from the base of the PR and between 7d2033b and 8fc6a81.

📒 Files selected for processing (1)
  • server/routes/commands.js

eric-wang-1990 and others added 2 commits March 6, 2026 15:11
…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>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
server/routes/commands.js (1)

101-113: Add defensive checks for JSON structure.

The code assumes plugins exists and each entry is an array with objects containing installPath. 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

📥 Commits

Reviewing files that changed from the base of the PR and between 8fc6a81 and 1a7756a.

📒 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>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Distinguish intentional closes from real disconnects.

On a token change, the previous cleanup closes the old socket, but the next effect immediately flips unmountedRef.current back to false. If that old socket’s onclose runs afterward, it schedules another reconnect and you can end up with parallel sockets / duplicate messages. This also misses sockets still in CONNECTING, because wsRef.current is only assigned in onopen.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 1a7756a and e47ad99.

📒 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>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
server/routes/commands.js (1)

744-746: Consider hardening @filename include resolution in command parser.

The command file path is now validated with fs.realpath(), but when the command content is processed, @filename includes are resolved using path.resolve() only (see processFileIncludes in server/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

📥 Commits

Reviewing files that changed from the base of the PR and between e47ad99 and e8711be.

📒 Files selected for processing (1)
  • server/routes/commands.js

Comment on lines +101 to +113
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"),
});
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +107 to +112
for (const install of installs) {
dirs.push({
pluginName,
commandsDir: path.join(install.installPath, "commands"),
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +579 to +583
const allowedRoots = [path.join(os.homedir(), ".claude", "commands")];
const pluginDirs = await getInstalledPluginCommandDirs();
for (const { commandsDir } of pluginDirs) {
allowedRoots.push(commandsDir);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

@viper151
Copy link
Contributor

viper151 commented Mar 9, 2026

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants