From cda8b80cfe897125b99ea6c566bb68a2afb36c6c Mon Sep 17 00:00:00 2001 From: xuyunfang Date: Wed, 30 Sep 2026 16:35:34 +0800 Subject: [PATCH] fix(skills): discover ~/.claude/skills, .claude/skills and ~/.stepcode/skills Skills authored for Claude Code live in ~/.claude/skills and project .claude/skills, which step never scanned, so users had to point the model at them by hand. Discover both alongside .agents/skills: the user directory always, project directories from cwd up to the git root once the project is trusted. A .claude/skills entry symlinked into ~/.agents/skills is loaded once. Also auto-discover ~/.stepcode/skills as user skills next to ~/.stepcode/agent/skills. When cwd is $HOME that directory is the user's own, so it is not read again as project skills and does not trigger the project trust prompt. Fixes #205 --- packages/coding-agent/README.md | 4 +- packages/coding-agent/docs/extensions.md | 2 +- packages/coding-agent/docs/sdk.md | 4 +- packages/coding-agent/docs/security.md | 2 +- packages/coding-agent/docs/settings.md | 2 +- packages/coding-agent/docs/skills.md | 9 +- packages/coding-agent/docs/usage.md | 2 +- .../coding-agent/src/core/package-manager.ts | 81 ++++++---- .../coding-agent/src/core/trust-manager.ts | 30 ++-- .../coding-agent/test/package-manager.test.ts | 150 +++++++++++++++++- .../coding-agent/test/trust-manager.test.ts | 11 ++ 11 files changed, 247 insertions(+), 50 deletions(-) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 6e5ae582..1f05c7f2 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -228,7 +228,7 @@ See [docs/settings.md](docs/settings.md) for all options. ### Project Trust -On interactive startup, pi asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. +On interactive startup, pi asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` or `.claude/skills` and has no saved decision for the folder or a parent folder in `~/.pi/agent/trust.json`. Trusting a project allows pi to load `.pi/settings.json` and `.pi` resources, install missing project packages, and execute project extensions. Before the trust decision, pi loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process. @@ -289,7 +289,7 @@ Use this skill when the user asks about X. 2. Then that ``` -Place in `~/.pi/agent/skills/`, `~/.agents/skills/`, `.pi/skills/`, or `.agents/skills/` (from `cwd` up through parent directories) or a [pi package](#pi-packages) to share with others. See [docs/skills.md](docs/skills.md). +Place in `~/.pi/agent/skills/`, `~/.pi/skills/`, `~/.agents/skills/`, `~/.claude/skills/`, `.pi/skills/`, `.agents/skills/`, or `.claude/skills/` (the last two from `cwd` up through parent directories) or a [pi package](#pi-packages) to share with others. See [docs/skills.md](docs/skills.md). ### Extensions diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 54b68cdf..0ca4bf05 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -352,7 +352,7 @@ exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM) #### project_trust -Fired before step decides whether to trust a project with dynamic configs (`.stepcode` or `.agents/skills`). It runs during startup and when session replacement (for example `/resume`) enters a cwd whose trust has not been resolved in the current process. Only user/global extensions and CLI `-e` extensions participate; project-local extensions are not loaded until after trust is resolved. +Fired before step decides whether to trust a project with dynamic configs (`.stepcode`, `.agents/skills`, or `.claude/skills`). It runs during startup and when session replacement (for example `/resume`) enters a cwd whose trust has not been resolved in the current process. Only user/global extensions and CLI `-e` extensions participate; project-local extensions are not loaded until after trust is resolved. ```typescript pi.on("project_trust", async (event, ctx) => { diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 2117adbb..402e3be3 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -345,7 +345,7 @@ const { session } = await createAgentSession({ - Project extensions (`.stepcode/extensions/`) - Project skills: - `.stepcode/skills/` - - `.agents/skills/` in `cwd` and ancestor directories (up to git repo root, or filesystem root when not in a repo) + - `.agents/skills/` and `.claude/skills/` in `cwd` and ancestor directories (up to git repo root, or filesystem root when not in a repo) - Project prompts (`.stepcode/prompts/`) - Context files (`AGENTS.md` walking up from cwd) - Session directory naming @@ -354,7 +354,9 @@ const { session } = await createAgentSession({ - Global extensions (`extensions/`) - Global skills: - `skills/` under `agentDir` (for example `~/.stepcode/agent/skills/`) + - `~/.stepcode/skills/` - `~/.agents/skills/` + - `~/.claude/skills/` - Global prompts (`prompts/`) - Global context file (`AGENTS.md`) - Settings (`settings.json`) diff --git a/packages/coding-agent/docs/security.md b/packages/coding-agent/docs/security.md index fdbb3fd8..687da984 100644 --- a/packages/coding-agent/docs/security.md +++ b/packages/coding-agent/docs/security.md @@ -11,7 +11,7 @@ Step considers a project to have resources that require trust when it finds any - `.stepcode/settings.json` - `.stepcode/extensions`, `.stepcode/skills`, `.stepcode/prompts`, or `.stepcode/themes` - `.stepcode/SYSTEM.md` or `.stepcode/APPEND_SYSTEM.md` -- project `.agents/skills` in the current directory or an ancestor directory +- project `.agents/skills` or `.claude/skills` in the current directory or an ancestor directory A bare `.stepcode` directory does not count as a project resource that requires trust. diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 9795cd48..3678118e 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -11,7 +11,7 @@ Edit directly or use `/settings` for common options. To save startup model defau ## Project Trust -On interactive startup, step asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.stepcode/agent/trust.json`. Trusting a project allows step to load `.stepcode/settings.json` and `.stepcode` resources, install missing project packages, and execute project extensions. +On interactive startup, step asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` or `.claude/skills` and has no saved decision for the folder or a parent folder in `~/.stepcode/agent/trust.json`. Trusting a project allows step to load `.stepcode/settings.json` and `.stepcode` resources, install missing project packages, and execute project extensions. Non-interactive modes (`-p`, `--mode json`, and `--mode rpc`) do not show a trust prompt. Without an applicable saved trust decision, they use `defaultProjectTrust` from global settings: `ask` (default) and `never` ignore those project resources, while `always` trusts them. Pass `--approve`/`-a` or `--no-approve`/`-na` to override project trust for one run. diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index 85ad6652..4188ad30 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -25,19 +25,22 @@ Step loads skills from: - Global: - `~/.stepcode/agent/skills/` + - `~/.stepcode/skills/` - `~/.agents/skills/` + - `~/.claude/skills/` (Claude Code's user skill directory) - Project (only after the project is trusted): - `.stepcode/skills/` - - `.agents/skills/` in `cwd` and ancestor directories (up to git repo root, or filesystem root when not in a repo) + - `.agents/skills/` and `.claude/skills/` in `cwd` and ancestor directories (up to git repo root, or filesystem root when not in a repo) - Packages: `skills/` directories or `pi.skills` entries in `package.json` - Settings: `skills` array with files or directories - CLI: `--skill ` (repeatable, additive even with `--no-skills`) Discovery rules: -- In `~/.stepcode/agent/skills/` and `.stepcode/skills/`, direct root `.md` files are discovered as individual skills when they have valid skill frontmatter with a non-empty `description` +- In `~/.stepcode/agent/skills/`, `~/.stepcode/skills/`, and `.stepcode/skills/`, direct root `.md` files are discovered as individual skills when they have valid skill frontmatter with a non-empty `description` - In all skill locations, directories containing `SKILL.md` are discovered recursively -- In `~/.agents/skills/` and project `.agents/skills/`, root `.md` files are ignored, but nested `.md` files in grouping folders are discovered when they declare skill frontmatter +- In `~/.agents/skills/`, `~/.claude/skills/`, and project `.agents/skills/` and `.claude/skills/`, root `.md` files are ignored, but nested `.md` files in grouping folders are discovered when they declare skill frontmatter - Root Markdown files other than `SKILL.md` that do not look like skills are ignored silently +- A skill reached through several locations (for example `~/.claude/skills/foo` symlinked to `~/.agents/skills/foo`) is loaded once - Directory symlinks are followed once per scan, so cycles do not cause repeated traversal - `.gitignore`, `.ignore`, and `.fdignore` rules are applied relative to the directory containing each ignore file diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index bbc49aec..4fdebeae 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -119,7 +119,7 @@ Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in eit ### Project Trust -On interactive startup, step asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` and has no saved decision for the folder or a parent folder in `~/.stepcode/agent/trust.json`. Trusting a project allows step to load `.stepcode/settings.json` and `.stepcode` resources, install missing project packages, and execute project extensions. +On interactive startup, step asks before trusting a project folder that contains project-local settings, resources, or project `.agents/skills` or `.claude/skills` and has no saved decision for the folder or a parent folder in `~/.stepcode/agent/trust.json`. Trusting a project allows step to load `.stepcode/settings.json` and `.stepcode` resources, install missing project packages, and execute project extensions. Before the trust decision, step loads only context files, user/global extensions, and CLI `-e` extensions so they can handle the `project_trust` event. Project-local extensions, project package-managed extensions, and project settings are loaded only after the project is trusted. This split also applies when switching to a session from a different cwd whose trust has not been resolved in the current process. diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 90684de8..3128a35c 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -432,14 +432,21 @@ function findGitRepoRoot(startDir: string): string | null { } } -function collectAncestorAgentsSkillDirs(startDir: string): string[] { +/** + * Directories whose `skills/` subdirectory is auto-discovered in the user's home + * and in cwd and its ancestors. `.claude` keeps skills authored for Claude Code + * usable without duplicating them. + */ +const SHARED_SKILL_DIR_NAMES = [".agents", ".claude"] as const; + +function collectAncestorSkillDirs(startDir: string, dirName: string): string[] { const skillDirs: string[] = []; const resolvedStartDir = resolve(startDir); const gitRepoRoot = findGitRepoRoot(resolvedStartDir); let dir = resolvedStartDir; while (true) { - skillDirs.push(join(dir, ".agents", "skills")); + skillDirs.push(join(dir, dirName, "skills")); if (gitRepoRoot && dir === gitRepoRoot) { break; } @@ -2358,12 +2365,19 @@ export class DefaultPackageManager implements PackageManager { prompts: join(projectBaseDir, "prompts"), themes: join(projectBaseDir, "themes"), }; - const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills"); + const userSharedSkillDirs = SHARED_SKILL_DIR_NAMES.map((name) => join(getHomeDir(), name, "skills")); + // ~/.stepcode/skills sits beside the agent dir rather than inside it. It is + // user-owned, so when cwd is $HOME it is not also read as a project directory. + const userConfigBaseDir = join(getHomeDir(), this.configDirName); + const userConfigSkillsDir = join(userConfigBaseDir, "skills"); + const projectSkillsIsUserConfig = resolve(projectDirs.skills) === resolve(userConfigSkillsDir); const projectTrusted = this.settingsManager.isProjectTrusted(); const includeSkills = accumulator.resourceTypes.includes("skills"); - const projectAgentsSkillDirs = + const projectSharedSkillDirs = includeSkills && projectTrusted - ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir)) + ? SHARED_SKILL_DIR_NAMES.flatMap((name) => collectAncestorSkillDirs(this.cwd, name)).filter( + (dir) => !userSharedSkillDirs.some((userDir) => resolve(dir) === resolve(userDir)), + ) : []; const addResources = ( @@ -2391,7 +2405,7 @@ export class DefaultPackageManager implements PackageManager { ); // Project skills from the product's configuration directory. - if (includeSkills) { + if (includeSkills && !projectSkillsIsUserConfig) { addResources( "skills", collectAutoSkillEntries(projectDirs.skills, "pi"), @@ -2402,19 +2416,19 @@ export class DefaultPackageManager implements PackageManager { } } - // Project skills from .agents/ (each with its own baseDir) - for (const agentsSkillsDir of projectAgentsSkillDirs) { - const agentsBaseDir = dirname(agentsSkillsDir); // the .agents directory - const agentsMetadata: PathMetadata = { + // Project skills from .agents/ and .claude/ (each with its own baseDir) + for (const sharedSkillsDir of projectSharedSkillDirs) { + const sharedBaseDir = dirname(sharedSkillsDir); // the .agents or .claude directory + const sharedMetadata: PathMetadata = { ...projectMetadata, - baseDir: agentsBaseDir, + baseDir: sharedBaseDir, }; addResources( "skills", - collectAutoSkillEntries(agentsSkillsDir, "agents"), - agentsMetadata, + collectAutoSkillEntries(sharedSkillsDir, "agents"), + sharedMetadata, projectOverrides.skills, - agentsBaseDir, + sharedBaseDir, ); } @@ -2454,19 +2468,32 @@ export class DefaultPackageManager implements PackageManager { 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, - ); + // User skills from ~/.stepcode/skills (the config dir, not the agent dir) + if (resolve(userConfigSkillsDir) !== resolve(userDirs.skills)) { + addResources( + "skills", + collectAutoSkillEntries(userConfigSkillsDir, "pi"), + { ...userMetadata, baseDir: userConfigBaseDir }, + userOverrides.skills, + userConfigBaseDir, + ); + } + + // User skills from ~/.agents/ and ~/.claude/ (each with its own baseDir) + for (const userSharedSkillsDir of userSharedSkillDirs) { + const userSharedBaseDir = dirname(userSharedSkillsDir); + const userSharedMetadata: PathMetadata = { + ...userMetadata, + baseDir: userSharedBaseDir, + }; + addResources( + "skills", + collectAutoSkillEntries(userSharedSkillsDir, "agents"), + userSharedMetadata, + userOverrides.skills, + userSharedBaseDir, + ); + } } addResources( diff --git a/packages/coding-agent/src/core/trust-manager.ts b/packages/coding-agent/src/core/trust-manager.ts index c258d3d8..74dc47a7 100644 --- a/packages/coding-agent/src/core/trust-manager.ts +++ b/packages/coding-agent/src/core/trust-manager.ts @@ -42,12 +42,13 @@ const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [ ] as const; /** - * Entries that double as the user's own global file at ~//. - * When cwd is $HOME the project path resolves to that same file, so treating it + * Entries that double as the user's own global file or directory at + * ~// (config.toml, and skills/ which is auto-discovered as + * user skills). When cwd is $HOME the project path resolves to that same entry, so treating it * as project input would prompt for trust on the user's own configuration - and * a "do not trust" answer there would be inherited by every project below $HOME. */ -const USER_GLOBAL_CONFIG_RESOURCES: ReadonlySet = new Set(["config.toml"]); +const USER_GLOBAL_CONFIG_RESOURCES: ReadonlySet = new Set(["config.toml", "skills"]); /** * Compare two already-resolved paths for filesystem equality. @@ -207,14 +208,16 @@ function withTrustFileLock(path: string, fn: () => T): T { /** * Returns true when cwd has project-local resources that must be gated by - * project trust: trust-requiring entries under cwd/.pi, or .agents/skills in - * cwd or one of its ancestors. Returns false when no such project resources - * exist. The user/global ~/.agents/skills directory is always treated as a - * trusted user resource and is ignored here, even when cwd is $HOME. + * project trust: trust-requiring entries under cwd/.pi, or .agents/skills or + * .claude/skills in cwd or one of its ancestors. Returns false when no such + * project resources exist. The user/global ~/.agents/skills and ~/.claude/skills + * directories are always treated as trusted user resources and are ignored + * here, even when cwd is $HOME. */ export function hasTrustRequiringProjectResources(cwd: string, configDirName: string = CONFIG_DIR_NAME): boolean { const homeDir = canonicalizePath(resolvePath(process.env.HOME || homedir())); - const userAgentsSkillsDir = join(homeDir, ".agents", "skills"); + const sharedSkillDirNames = [".agents", ".claude"]; + const userSharedSkillDirs = sharedSkillDirNames.map((name) => join(homeDir, name, "skills")); let currentDir = canonicalizePath(resolvePath(cwd)); const resolvedConfigDirName = configDirName.trim() || CONFIG_DIR_NAME; @@ -228,9 +231,14 @@ export function hasTrustRequiringProjectResources(cwd: string, configDirName: st } while (true) { - const agentsSkillsDir = join(currentDir, ".agents", "skills"); - if (!isSamePath(agentsSkillsDir, userAgentsSkillsDir) && existsSync(agentsSkillsDir)) { - return true; + for (const name of sharedSkillDirNames) { + const sharedSkillsDir = join(currentDir, name, "skills"); + if ( + !userSharedSkillDirs.some((userDir) => isSamePath(sharedSkillsDir, userDir)) && + existsSync(sharedSkillsDir) + ) { + return true; + } } const parentDir = dirname(currentDir); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 8393f2fc..59d086eb 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -228,9 +228,11 @@ Content`, }); // Project auto-discovered has higher precedence than user auto-discovered, - // so the surviving entry should be scoped to project. + // so the surviving entry should be scoped to project. Skills are the + // exception: with cwd at $HOME, ~/.pi/skills is the user's own skill + // directory rather than project input. expect(result.extensions[0].metadata.scope).toBe("project"); - expect(result.skills[0].metadata.scope).toBe("project"); + expect(result.skills[0].metadata.scope).toBe("user"); expect(result.prompts[0].metadata.scope).toBe("project"); expect(result.themes[0].metadata.scope).toBe("project"); } finally { @@ -526,6 +528,150 @@ Content`, }); }); + describe("~//skills auto-discovery", () => { + it("should discover user skills from ~/.pi/skills with ~/.pi as baseDir", async () => { + const previousHome = process.env.HOME; + process.env.HOME = tempDir; + + try { + const configBaseDir = join(tempDir, ".pi"); + const skillPath = join(configBaseDir, "skills", "config-dir", "SKILL.md"); + mkdirSync(join(configBaseDir, "skills", "config-dir"), { recursive: true }); + writeFileSync(skillPath, "---\nname: config-dir\ndescription: config dir\n---\n"); + + const result = await packageManager.resolve(); + const skill = result.skills.find((r) => r.path === skillPath); + + expect(skill?.enabled).toBe(true); + expect(skill?.metadata.source).toBe("auto"); + expect(skill?.metadata.scope).toBe("user"); + expect(skill?.metadata.baseDir).toBe(configBaseDir); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + } + }); + + it("should keep ~/.pi/skills user-scoped and loaded once when cwd is $HOME", async () => { + const previousHome = process.env.HOME; + process.env.HOME = tempDir; + + try { + const skillPath = join(tempDir, ".pi", "skills", "home-config", "SKILL.md"); + mkdirSync(join(tempDir, ".pi", "skills", "home-config"), { recursive: true }); + writeFileSync(skillPath, "---\nname: home-config\ndescription: home config\n---\n"); + + const pm = new DefaultPackageManager({ + cwd: tempDir, + agentDir, + settingsManager, + }); + + const result = await pm.resolve(); + const matchingSkills = result.skills.filter((r) => r.path === skillPath); + + expect(matchingSkills).toHaveLength(1); + expect(matchingSkills[0]?.enabled).toBe(true); + expect(matchingSkills[0]?.metadata.scope).toBe("user"); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + } + }); + }); + + describe(".claude/skills auto-discovery", () => { + it("should discover user skills from ~/.claude/skills with ~/.claude as baseDir", async () => { + const previousHome = process.env.HOME; + process.env.HOME = tempDir; + + try { + const claudeBaseDir = join(tempDir, ".claude"); + const skillPath = join(claudeBaseDir, "skills", "user-claude", "SKILL.md"); + mkdirSync(join(claudeBaseDir, "skills", "user-claude"), { recursive: true }); + writeFileSync(skillPath, "---\nname: user-claude\ndescription: user claude\n---\n"); + + const result = await packageManager.resolve(); + const skill = result.skills.find((r) => r.path === skillPath); + + expect(skill?.enabled).toBe(true); + expect(skill?.metadata.source).toBe("auto"); + expect(skill?.metadata.scope).toBe("user"); + expect(skill?.metadata.baseDir).toBe(claudeBaseDir); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + } + }); + + it("should scan project .claude/skills from cwd up to git repo root", async () => { + const repoRoot = join(tempDir, "repo"); + const nestedCwd = join(repoRoot, "packages", "feature"); + mkdirSync(nestedCwd, { recursive: true }); + mkdirSync(join(repoRoot, ".git"), { recursive: true }); + + const aboveRepoSkill = join(tempDir, ".claude", "skills", "above-repo", "SKILL.md"); + mkdirSync(join(tempDir, ".claude", "skills", "above-repo"), { recursive: true }); + writeFileSync(aboveRepoSkill, "---\nname: above-repo\ndescription: above\n---\n"); + + const repoClaudeBaseDir = join(repoRoot, ".claude"); + const repoSkill = join(repoClaudeBaseDir, "skills", "repo", "SKILL.md"); + mkdirSync(join(repoClaudeBaseDir, "skills", "repo"), { recursive: true }); + writeFileSync(repoSkill, "---\nname: repo\ndescription: repo\n---\n"); + + const pm = new DefaultPackageManager({ + cwd: nestedCwd, + agentDir, + settingsManager, + }); + + const result = await pm.resolve(); + const resolvedRepoSkill = result.skills.find((r) => r.path === repoSkill); + + expect(resolvedRepoSkill?.enabled).toBe(true); + expect(resolvedRepoSkill?.metadata.scope).toBe("project"); + expect(resolvedRepoSkill?.metadata.baseDir).toBe(repoClaudeBaseDir); + expect(result.skills.some((r) => r.path === aboveRepoSkill)).toBe(false); + }); + + it("should dedupe ~/.claude/skills entries that symlink to ~/.agents/skills", async () => { + const previousHome = process.env.HOME; + process.env.HOME = tempDir; + + try { + const agentsSkillDir = join(tempDir, ".agents", "skills", "linked"); + mkdirSync(agentsSkillDir, { recursive: true }); + writeFileSync(join(agentsSkillDir, "SKILL.md"), "---\nname: linked\ndescription: linked\n---\n"); + + const claudeSkillsDir = join(tempDir, ".claude", "skills"); + mkdirSync(claudeSkillsDir, { recursive: true }); + const directoryLinkType = process.platform === "win32" ? "junction" : "dir"; + symlinkSync(agentsSkillDir, join(claudeSkillsDir, "linked"), directoryLinkType); + + const result = await packageManager.resolve(); + const linkedSkills = result.skills.filter((r) => pathEndsWith(r.path, "linked/SKILL.md")); + + expect(linkedSkills).toHaveLength(1); + expect(linkedSkills[0]?.enabled).toBe(true); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + } + }); + }); + describe("ignore files", () => { it("should respect .gitignore in skill directories", async () => { const skillsDir = join(agentDir, "skills"); diff --git a/packages/coding-agent/test/trust-manager.test.ts b/packages/coding-agent/test/trust-manager.test.ts index 8db04b84..6ebc5724 100644 --- a/packages/coding-agent/test/trust-manager.test.ts +++ b/packages/coding-agent/test/trust-manager.test.ts @@ -42,6 +42,7 @@ describe("ProjectTrustStore", () => { try { mkdirSync(join(tempDir, ".pi", "agent"), { recursive: true }); mkdirSync(join(tempDir, ".agents", "skills"), { recursive: true }); + mkdirSync(join(tempDir, ".claude", "skills"), { recursive: true }); expect(hasTrustRequiringProjectResources(tempDir)).toBe(false); expect(hasTrustRequiringProjectResources(cwd)).toBe(false); @@ -64,12 +65,22 @@ describe("ProjectTrustStore", () => { expect(hasTrustRequiringProjectResources(cwd)).toBe(false); rmSync(join(tempDir, ".pi", "config.toml"), { force: true }); + // ~/.pi/skills is auto-discovered as user skills, so it is not project input either. + mkdirSync(join(tempDir, ".pi", "skills"), { recursive: true }); + expect(hasTrustRequiringProjectResources(tempDir)).toBe(false); + rmSync(join(tempDir, ".pi", "skills"), { recursive: true, force: true }); + writeFileSync(join(cwd, ".pi", "settings.json"), "{}"); expect(hasTrustRequiringProjectResources(cwd)).toBe(true); rmSync(join(cwd, ".pi"), { recursive: true, force: true }); mkdirSync(join(cwd, ".agents", "skills"), { recursive: true }); expect(hasTrustRequiringProjectResources(cwd)).toBe(true); + + rmSync(join(cwd, ".agents"), { recursive: true, force: true }); + expect(hasTrustRequiringProjectResources(cwd)).toBe(false); + mkdirSync(join(cwd, ".claude", "skills"), { recursive: true }); + expect(hasTrustRequiringProjectResources(cwd)).toBe(true); } finally { if (originalHome === undefined) { delete process.env.HOME;