Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 123 additions & 39 deletions cli/src/modules/common/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { parse as parseYaml } from 'yaml';
export interface SlashCommand {
name: string;
description?: string;
source: 'builtin' | 'user';
source: 'builtin' | 'user' | 'plugin';
content?: string; // Expanded content for Codex user prompts
pluginName?: string; // Name of the plugin that provides this command
}

export interface ListSlashCommandsRequest {
Expand Down Expand Up @@ -40,6 +41,21 @@ const BUILTIN_COMMANDS: Record<string, SlashCommand[]> = {
opencode: [],
};

/**
* Interface for installed_plugins.json structure
*/
interface InstalledPluginsFile {
version: number;
plugins: Record<string, Array<{
scope: string;
installPath: string;
version: string;
installedAt: string;
lastUpdated: string;
gitCommitSha?: string;
}>>;
}

/**
* Parse frontmatter from a markdown file content.
* Returns the description (from frontmatter) and the body content.
Expand Down Expand Up @@ -84,49 +100,48 @@ function getUserCommandsDir(agent: string): string | null {
}

/**
* Scan a directory for user-defined commands (*.md files).
* For Codex, reads file content and parses frontmatter.
* Returns the command names (filename without extension).
* Scan a directory for commands (*.md files).
* Returns commands with parsed frontmatter.
*/
async function scanUserCommands(agent: string): Promise<SlashCommand[]> {
const dir = getUserCommandsDir(agent);
if (!dir) {
return [];
}

const shouldReadContent = agent === 'codex';

async function scanCommandsDir(
dir: string,
source: 'user' | 'plugin',
pluginName?: string
): Promise<SlashCommand[]> {
try {
const entries = await readdir(dir, { withFileTypes: true });
const mdFiles = entries.filter(e => e.isFile() && e.name.endsWith('.md'));

// Read all files in parallel
const commands = await Promise.all(
mdFiles.map(async (entry): Promise<SlashCommand | null> => {
const name = entry.name.slice(0, -3);
if (!name) return null;

const command: SlashCommand = {
name,
description: 'Custom command',
source: 'user',
};

if (shouldReadContent) {
try {
const filePath = join(dir, entry.name);
const fileContent = await readFile(filePath, 'utf-8');
const parsed = parseFrontmatter(fileContent);
if (parsed.description) {
command.description = parsed.description;
}
command.content = parsed.content;
} catch {
// Failed to read file, keep default description
}
const baseName = entry.name.slice(0, -3);
if (!baseName) return null;

// For plugin commands, prefix with plugin name (e.g., "superpowers:brainstorm")
const name = pluginName ? `${pluginName}:${baseName}` : baseName;

try {
const filePath = join(dir, entry.name);
const fileContent = await readFile(filePath, 'utf-8');
const parsed = parseFrontmatter(fileContent);

return {
name,
description: parsed.description ?? (source === 'plugin' ? `${pluginName} command` : 'Custom command'),
source,
content: parsed.content,
pluginName,
};
} catch {
// Failed to read file, return basic command
return {
name,
description: source === 'plugin' ? `${pluginName} command` : 'Custom command',
source,
pluginName,
};
}

return command;
})
);

Expand All @@ -140,14 +155,83 @@ async function scanUserCommands(agent: string): Promise<SlashCommand[]> {
}
}

/**
* Scan user-defined commands from ~/.claude/commands/ or equivalent
*/
async function scanUserCommands(agent: string): Promise<SlashCommand[]> {
const dir = getUserCommandsDir(agent);
if (!dir) {
return [];
}
return scanCommandsDir(dir, 'user');
}

/**
* Scan plugin commands from installed Claude plugins.
* Reads ~/.claude/plugins/installed_plugins.json to find installed plugins,
* then scans each plugin's commands directory.
*/
async function scanPluginCommands(agent: string): Promise<SlashCommand[]> {
// Only Claude supports plugins for now
if (agent !== 'claude') {
return [];
}

const configDir = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude');
const installedPluginsPath = join(configDir, 'plugins', 'installed_plugins.json');

try {
const content = await readFile(installedPluginsPath, 'utf-8');
const installedPlugins = JSON.parse(content) as InstalledPluginsFile;

if (!installedPlugins.plugins) {
return [];
}

const allCommands: SlashCommand[] = [];

// Process each installed plugin
for (const [pluginKey, installations] of Object.entries(installedPlugins.plugins)) {
// Plugin key format: "pluginName@marketplace" or "@scope/pluginName@marketplace"
// Use the last '@' as the separator between plugin name and marketplace
const lastAtIndex = pluginKey.lastIndexOf('@');
const pluginName = lastAtIndex > 0 ? pluginKey.substring(0, lastAtIndex) : pluginKey;

if (installations.length === 0) continue;

// Sort installations by lastUpdated descending to get the newest one
const sortedInstallations = [...installations].sort((a, b) => {
return new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime();
});

const installation = sortedInstallations[0];
if (!installation?.installPath) continue;

const commandsDir = join(installation.installPath, 'commands');
const commands = await scanCommandsDir(commandsDir, 'plugin', pluginName);
allCommands.push(...commands);
}

return allCommands.sort((a, b) => a.name.localeCompare(b.name));
} catch {
// installed_plugins.json doesn't exist or is invalid
return [];
}
}

/**
* List all available slash commands for an agent type.
* Returns built-in commands plus user-defined commands.
* Returns built-in commands, user-defined commands, and plugin commands.
*/
export async function listSlashCommands(agent: string): Promise<SlashCommand[]> {
const builtin = BUILTIN_COMMANDS[agent] ?? [];
const user = await scanUserCommands(agent);

// Combine: built-in first, then user commands
return [...builtin, ...user];
// Scan user commands and plugin commands in parallel
const [user, plugin] = await Promise.all([
scanUserCommands(agent),
scanPluginCommands(agent),
]);

// Combine: built-in first, then user commands, then plugin commands
return [...builtin, ...user, ...plugin];
}
10 changes: 6 additions & 4 deletions web/src/hooks/queries/useSlashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,16 @@ export function useSlashCommands(
retry: false, // Don't retry RPC failures
})

// Merge built-in commands with user-defined commands from API
// Merge built-in commands with user-defined and plugin commands from API
const commands = useMemo(() => {
const builtin = BUILTIN_COMMANDS[agentType] ?? BUILTIN_COMMANDS['claude'] ?? []

// If API succeeded, add user-defined commands
// If API succeeded, add user-defined and plugin commands
if (query.data?.success && query.data.commands) {
const userCommands = query.data.commands.filter(cmd => cmd.source === 'user')
return [...builtin, ...userCommands]
const extraCommands = query.data.commands.filter(
cmd => cmd.source === 'user' || cmd.source === 'plugin'
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] Type union missing plugin

Why this is a problem: cmd.source === 'plugin' now relies on a source variant that is not in SlashCommand.source, which makes this condition invalid under TS strict and will fail typecheck.

Suggested fix:

export type SlashCommand = {
    name: string
    description?: string
    source: 'builtin' | 'user' | 'plugin'
    content?: string  // Expanded content for Codex user prompts
    pluginName?: string
}

return [...builtin, ...extraCommands]
}

// Fallback to built-in commands only
Expand Down
2 changes: 1 addition & 1 deletion web/src/hooks/useActiveSuggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export interface Suggestion {
label: string
description?: string
content?: string // Expanded content for Codex user prompts
source?: 'builtin' | 'user'
source?: 'builtin' | 'user' | 'plugin'
}

interface SuggestionOptions {
Expand Down
3 changes: 2 additions & 1 deletion web/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,9 @@ export type GitStatusFiles = {
export type SlashCommand = {
name: string
description?: string
source: 'builtin' | 'user'
source: 'builtin' | 'user' | 'plugin'
content?: string // Expanded content for Codex user prompts
pluginName?: string
}

export type SlashCommandsResponse = {
Expand Down