From 0f95975103f0429747698f2da13427928573fcbc Mon Sep 17 00:00:00 2001 From: Aliou Diallo Date: Thu, 30 Apr 2026 19:35:37 +0200 Subject: [PATCH] fix(coding-agent): preserve .agents provenance in skill metadata fixes #3978 --- .../coding-agent/src/core/package-manager.ts | 45 ++++++++-- .../interactive/components/config-selector.ts | 25 +++++- .../coding-agent/test/package-manager.test.ts | 88 +++++++++++++++++++ 3 files changed, 152 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 981f2d92..8cd1baa9 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -2182,6 +2182,7 @@ export class DefaultPackageManager implements PackageManager { } }; + // Project extensions from .pi/ addResources( "extensions", collectAutoExtensionEntries(projectDirs.extensions), @@ -2189,16 +2190,32 @@ export class DefaultPackageManager implements PackageManager { projectOverrides.extensions, projectBaseDir, ); + + // Project skills from .pi/ addResources( "skills", - [ - ...collectAutoSkillEntries(projectDirs.skills, "pi"), - ...projectAgentsSkillDirs.flatMap((dir) => collectAutoSkillEntries(dir, "agents")), - ], + collectAutoSkillEntries(projectDirs.skills, "pi"), projectMetadata, projectOverrides.skills, projectBaseDir, ); + + // Project skills from .agents/ (each with its own baseDir) + for (const agentsSkillsDir of projectAgentsSkillDirs) { + const agentsBaseDir = dirname(agentsSkillsDir); // the .agents directory + const agentsMetadata: PathMetadata = { + ...projectMetadata, + baseDir: agentsBaseDir, + }; + addResources( + "skills", + collectAutoSkillEntries(agentsSkillsDir, "agents"), + agentsMetadata, + projectOverrides.skills, + agentsBaseDir, + ); + } + addResources( "prompts", collectAutoPromptEntries(projectDirs.prompts), @@ -2214,6 +2231,7 @@ export class DefaultPackageManager implements PackageManager { projectBaseDir, ); + // User extensions from ~/.pi/agent/ addResources( "extensions", collectAutoExtensionEntries(userDirs.extensions), @@ -2221,13 +2239,30 @@ export class DefaultPackageManager implements PackageManager { userOverrides.extensions, globalBaseDir, ); + + // User skills from ~/.pi/agent/ addResources( "skills", - [...collectAutoSkillEntries(userDirs.skills, "pi"), ...collectAutoSkillEntries(userAgentsSkillsDir, "agents")], + collectAutoSkillEntries(userDirs.skills, "pi"), userMetadata, userOverrides.skills, globalBaseDir, ); + + // User skills from ~/.agents/ (with its own baseDir) + const userAgentsBaseDir = dirname(userAgentsSkillsDir); + const userAgentsMetadata: PathMetadata = { + ...userMetadata, + baseDir: userAgentsBaseDir, + }; + addResources( + "skills", + collectAutoSkillEntries(userAgentsSkillsDir, "agents"), + userAgentsMetadata, + userOverrides.skills, + userAgentsBaseDir, + ); + addResources( "prompts", collectAutoPromptEntries(userDirs.prompts), diff --git a/packages/coding-agent/src/modes/interactive/components/config-selector.ts b/packages/coding-agent/src/modes/interactive/components/config-selector.ts index c321dfe5..33ae77e6 100644 --- a/packages/coding-agent/src/modes/interactive/components/config-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/config-selector.ts @@ -2,6 +2,7 @@ * TUI component for managing package resources (enable/disable) */ +import { homedir } from "node:os"; import { basename, dirname, join, relative } from "node:path"; import { type Component, @@ -55,12 +56,34 @@ interface ResourceGroup { subgroups: ResourceSubgroup[]; } +function formatBaseDir(baseDir: string): string { + const homeDir = homedir(); + let displayPath: string; + + if (baseDir === homeDir) { + displayPath = "~"; + } else if (baseDir.startsWith(homeDir)) { + // Replace home prefix with ~, normalize separators for display + const rest = baseDir.slice(homeDir.length); + displayPath = `~${rest.replace(/\\/g, "/")}`; + } else { + displayPath = baseDir.replace(/\\/g, "/"); + } + + return displayPath.endsWith("/") ? displayPath : `${displayPath}/`; +} + function getGroupLabel(metadata: PathMetadata): string { if (metadata.origin === "package") { return `${metadata.source} (${metadata.scope})`; } // Top-level resources if (metadata.source === "auto") { + if (metadata.baseDir) { + return metadata.scope === "user" + ? `User (${formatBaseDir(metadata.baseDir)})` + : `Project (${formatBaseDir(metadata.baseDir)})`; + } return metadata.scope === "user" ? "User (~/.pi/agent/)" : "Project (.pi/)"; } return metadata.scope === "user" ? "User settings" : "Project settings"; @@ -72,7 +95,7 @@ function buildGroups(resolved: ResolvedPaths): ResourceGroup[] { const addToGroup = (resources: ResolvedResource[], resourceType: ResourceType) => { for (const res of resources) { const { path, enabled, metadata } = res; - const groupKey = `${metadata.origin}:${metadata.scope}:${metadata.source}`; + const groupKey = `${metadata.origin}:${metadata.scope}:${metadata.source}:${metadata.baseDir ?? ""}`; if (!groupMap.has(groupKey)) { groupMap.set(groupKey, { diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 636877b3..18128a83 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -274,6 +274,94 @@ Content`, }); }); + describe("auto-discovered skill metadata", () => { + it("should use the agent dir as baseDir for user .pi/agent skills", async () => { + const skillPath = join(agentDir, "skills", "user-pi", "SKILL.md"); + mkdirSync(join(agentDir, "skills", "user-pi"), { recursive: true }); + writeFileSync(skillPath, "---\nname: user-pi\ndescription: user pi\n---\n"); + + const result = await packageManager.resolve(); + const skill = result.skills.find((r) => r.path === skillPath); + + expect(skill?.metadata.source).toBe("auto"); + expect(skill?.metadata.scope).toBe("user"); + expect(skill?.metadata.baseDir).toBe(agentDir); + }); + + it("should use the project .pi dir as baseDir for project .pi skills", async () => { + const projectBaseDir = join(tempDir, ".pi"); + const skillPath = join(projectBaseDir, "skills", "project-pi", "SKILL.md"); + mkdirSync(join(projectBaseDir, "skills", "project-pi"), { recursive: true }); + writeFileSync(skillPath, "---\nname: project-pi\ndescription: project pi\n---\n"); + + const result = await packageManager.resolve(); + const skill = result.skills.find((r) => r.path === skillPath); + + expect(skill?.metadata.source).toBe("auto"); + expect(skill?.metadata.scope).toBe("project"); + expect(skill?.metadata.baseDir).toBe(projectBaseDir); + }); + + it("should use ~/.agents as baseDir for user .agents skills", async () => { + const previousHome = process.env.HOME; + process.env.HOME = tempDir; + + try { + const agentsBaseDir = join(tempDir, ".agents"); + const skillPath = join(agentsBaseDir, "skills", "user-agents", "SKILL.md"); + mkdirSync(join(agentsBaseDir, "skills", "user-agents"), { recursive: true }); + writeFileSync(skillPath, "---\nname: user-agents\ndescription: user agents\n---\n"); + + const result = await packageManager.resolve(); + const skill = result.skills.find((r) => r.path === skillPath); + + expect(skill?.metadata.source).toBe("auto"); + expect(skill?.metadata.scope).toBe("user"); + expect(skill?.metadata.baseDir).toBe(agentsBaseDir); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + } + }); + + it("should use each project .agents dir as baseDir for project .agents skills", async () => { + const repoRoot = join(tempDir, "repo"); + const nestedCwd = join(repoRoot, "packages", "feature"); + mkdirSync(nestedCwd, { recursive: true }); + mkdirSync(join(repoRoot, ".git"), { recursive: true }); + + const repoAgentsBaseDir = join(repoRoot, ".agents"); + const repoSkill = join(repoAgentsBaseDir, "skills", "repo", "SKILL.md"); + mkdirSync(join(repoAgentsBaseDir, "skills", "repo"), { recursive: true }); + writeFileSync(repoSkill, "---\nname: repo\ndescription: repo\n---\n"); + + const packageAgentsBaseDir = join(repoRoot, "packages", ".agents"); + const packageSkill = join(packageAgentsBaseDir, "skills", "package", "SKILL.md"); + mkdirSync(join(packageAgentsBaseDir, "skills", "package"), { recursive: true }); + writeFileSync(packageSkill, "---\nname: package\ndescription: package\n---\n"); + + const pm = new DefaultPackageManager({ + cwd: nestedCwd, + agentDir, + settingsManager, + }); + + const result = await pm.resolve(); + const resolvedRepoSkill = result.skills.find((r) => r.path === repoSkill); + const resolvedPackageSkill = result.skills.find((r) => r.path === packageSkill); + + expect(resolvedRepoSkill?.metadata.source).toBe("auto"); + expect(resolvedRepoSkill?.metadata.scope).toBe("project"); + expect(resolvedRepoSkill?.metadata.baseDir).toBe(repoAgentsBaseDir); + expect(resolvedPackageSkill?.metadata.source).toBe("auto"); + expect(resolvedPackageSkill?.metadata.scope).toBe("project"); + expect(resolvedPackageSkill?.metadata.baseDir).toBe(packageAgentsBaseDir); + }); + }); + describe(".agents/skills auto-discovery", () => { it("should scan .agents/skills from cwd up to git repo root", async () => { const repoRoot = join(tempDir, "repo");