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
10 changes: 8 additions & 2 deletions src/core/registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -481,8 +481,14 @@ export function buildRegistry(customHome = null) {
Object.entries(agentDirs)
.filter(([agentKey, cfg]) => isAgentVisible(cfg, agentKey, overrides))
// An Agent that cannot read the Skill has no trigger word for it.
// Listing one told the user to type a command that does nothing.
.filter(([agentKey]) => !agentOnly || agentKey === agentOnly)
// Listing one tells the user to type a command that does nothing.
//
// `agents` above already answers "can this Agent read it": the link
// exists, or the Agent scans the SSOT natively. Asking that map is
// what makes this true after an unlink or a trash restore too — both
// leave the Skill in place with its links gone, and both used to keep
// advertising the trigger word for Agents that had just lost it.
.filter(([agentKey]) => agents[agentKey])
.map(([agentKey, cfg]) => [
agentKey,
`${cfg.triggerPrefix || "/"}${cfg.type === "native" ? fmName || name : name}`,
Expand Down
16 changes: 16 additions & 0 deletions test/registry.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ test("registry uses configured agent triggers and metadata overrides", (t) => {
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: demo\ndescription: demo skill\n---\n");
// cursor is switched on explicitly; gemini is left to the default, and its
// directory does not exist in this sandbox.
// The link has to exist for cursor to have a trigger word at all: a trigger
// is what an Agent that can reach the Skill would answer to, and cursor can
// only reach it through this link.
const cursorSkills = join(tmp, ".cursor", "skills");
mkdirSync(cursorSkills, { recursive: true });
symlinkSync(skillDir, join(cursorSkills, "demo"), "dir");
saveUserOverrides(join(tmp, ".skillhub", "overrides.json"), {
notesOverrides: { demo: "portable note" },
managedSkills: { demo: "external-manager" },
Expand All @@ -108,6 +114,16 @@ test("registry uses configured agent triggers and metadata overrides", (t) => {
assert.equal(reg.skills.demo.triggers.codex, "$demo");
assert.equal(reg.skills.demo.triggers.cursor, "/demo");

// Take the link away and the trigger word goes with it. An Agent that cannot
// reach the Skill has no command to offer, and printing one sends the user to
// type something that does nothing — which is what `unlink` and a restore
// from the trash both used to leave behind.
rmSync(join(cursorSkills, "demo"));
const afterUnlink = buildRegistry(tmp);
assert.equal(afterUnlink.skills.demo.agents.cursor, false);
assert.equal(afterUnlink.skills.demo.triggers.cursor, undefined);
assert.equal(afterUnlink.skills.demo.triggers.codex, "$demo", "the native Agent still reaches it");

// An Agent that is not in use is left out of the registry entirely, so the
// switch means the same thing to `scan`, to the sync planner and to the
// dashboard.
Expand Down