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
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Fixed

- Fixed Claude marketplace plugin skills appearing as bare slash commands while keeping real plugin `commands/` entries available ([#2645](https://github.com/can1357/oh-my-pi/issues/2645)).

## [15.13.3] - 2026-06-15

### Added
Expand Down
45 changes: 3 additions & 42 deletions packages/coding-agent/src/discovery/claude-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,43 +124,6 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
}
return { items, warnings };
}
async function loadSkillSlashCommands(ctx: LoadContext, root: ClaudePluginRoot): Promise<LoadResult<SlashCommand>> {
const { dir: skillsDir, warning } = await resolvePluginDir(root, ["skills"], "skills");
const warnings: string[] = warning ? [warning] : [];
const skillsResult = await scanSkillsFromDir(ctx, {
dir: skillsDir,
providerId: PROVIDER_ID,
level: root.scope,
});
warnings.push(...(skillsResult.warnings ?? []));

const commands = await Promise.all(
skillsResult.items.map(async skill => {
const content = await readFile(skill.path);
if (content === null) {
warnings.push(`Failed to read skill slash command: ${skill.path}`);
return null;
}
// Slash command name MUST come from the skill directory basename, not
// frontmatter `name`: `expandSlashCommand` splits the command at the first
// whitespace, so a display name like "Understand Anything" would never match
// `/understand`. The documented layout is `skills/<name>/SKILL.md` → `/<name>`.
const command: SlashCommand = {
name: path.basename(path.dirname(skill.path)),
path: skill.path,
content,
level: skill.level,
_source: skill._source,
};
return command;
}),
);

return {
items: commands.filter((command): command is SlashCommand => command !== null),
warnings,
};
}

// =============================================================================
// Slash Commands
Expand Down Expand Up @@ -189,16 +152,14 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
};
},
});
const skillCommandResult = await loadSkillSlashCommands(ctx, root);
return { commandResult, skillCommandResult, warning };
return { commandResult, warning };
}),
);

for (const { commandResult, skillCommandResult, warning } of results) {
for (const { commandResult, warning } of results) {
if (warning) warnings.push(warning);
items.push(...commandResult.items, ...skillCommandResult.items);
items.push(...commandResult.items);
if (commandResult.warnings) warnings.push(...commandResult.warnings);
if (skillCommandResult.warnings) warnings.push(...skillCommandResult.warnings);
}

return { items, warnings };
Expand Down
49 changes: 8 additions & 41 deletions packages/coding-agent/test/discovery/claude-plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
listClaudePluginRoots,
parseClaudePluginsRegistry,
} from "@oh-my-pi/pi-coding-agent/discovery/helpers";
import { expandSlashCommand, loadSlashCommands } from "@oh-my-pi/pi-coding-agent/extensibility/slash-commands";
import { loadSlashCommands } from "@oh-my-pi/pi-coding-agent/extensibility/slash-commands";
import { discoverAgents } from "@oh-my-pi/pi-coding-agent/task/discovery";
import "@oh-my-pi/pi-coding-agent/discovery/claude-plugins";
import type { Skill } from "@oh-my-pi/pi-coding-agent/capability/skill";
Expand Down Expand Up @@ -356,9 +356,9 @@ describe("listClaudePluginRoots", () => {
expect(found).toBeDefined();
expect(found?.path).toContain(path.join(".claude", "skills", "manifest-skill", "SKILL.md"));
});
test("exposes plugin skills as bare slash commands", async () => {
const pluginsDir = path.join(tempDir, ".omp", "plugins");
const pluginPath = path.join(tempDir, ".omp", "plugins", "cache", "plugins", "understand-anything");
test("keeps plugin skills out of slash commands while loading them as skills", async () => {
const pluginsDir = path.join(tempDir, ".claude", "plugins");
const pluginPath = path.join(tempDir, "plugins", "understand-anything");
await fs.mkdir(pluginsDir, { recursive: true });
await fs.mkdir(path.join(pluginPath, "skills", "understand"), { recursive: true });

Expand All @@ -384,45 +384,12 @@ describe("listClaudePluginRoots", () => {
);

const commands = await loadSlashCommands({ cwd: tempDir });
const found = commands.find(command => command.name === "understand");

expect(found?.description).toBe("Build an understanding graph");
expect(expandSlashCommand("/understand --language zh", commands)).toContain("Analyze the project.");
});
test("uses skill directory basename when frontmatter name contains spaces", async () => {
const pluginsDir = path.join(tempDir, ".omp", "plugins");
const pluginPath = path.join(tempDir, ".omp", "plugins", "cache", "plugins", "display-name-skill");
await fs.mkdir(pluginsDir, { recursive: true });
await fs.mkdir(path.join(pluginPath, "skills", "understand"), { recursive: true });

const registry = {
version: 2,
plugins: {
"display-name-skill@display-name-skill": [
{
scope: "user",
installPath: pluginPath,
version: "1.0.0",
installedAt: "2026-06-12T00:00:00Z",
lastUpdated: "2026-06-12T00:00:00Z",
},
],
},
};
const skills = await loadCapability<Skill>("skills", { cwd: tempDir });

await fs.writeFile(path.join(pluginsDir, "installed_plugins.json"), JSON.stringify(registry));
await fs.writeFile(
path.join(pluginPath, "skills", "understand", "SKILL.md"),
"---\nname: Understand Anything\ndescription: Build an understanding graph\n---\nAnalyze the project.\n",
expect(commands.find(command => command.name === "understand")).toBeUndefined();
expect(skills.all.find(skill => skill.name === "understand")?.frontmatter?.description).toBe(
"Build an understanding graph",
);

const commands = await loadSlashCommands({ cwd: tempDir });
// Skill is registered by directory basename so `/understand` resolves,
// even though the frontmatter `name` is the multi-word display label.
const found = commands.find(command => command.name === "understand");
expect(found?.description).toBe("Build an understanding graph");
expect(commands.find(command => command.name === "Understand Anything")).toBeUndefined();
expect(expandSlashCommand("/understand", commands)).toContain("Analyze the project.");
});

test("reads slash commands directory from plugin manifest slash-commands field", async () => {
Expand Down
Loading