diff --git a/AGENTS.md b/AGENTS.md index 1a8cf10a..7ec327d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ read README.md, then ask which module(s) to work on. Based on the answer, read t - After code changes (not documentation changes): `npm run check` (get full output, no tail). Fix all errors, warnings, and infos before committing. - NEVER run: `npm run dev`, `npm run build`, `npm test` - Only run specific tests if user instructs: `npm test -- test/specific.test.ts` +- When writing tests, run them, identify issues in either the test or implementation, and iterate until fixed. - NEVER commit unless user asks ## GitHub Issues diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3baaecd0..a9ac50d6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Read tool now handles macOS filenames with curly quotes (U+2019) and NFD Unicode normalization ([#1078](https://github.com/badlogic/pi-mono/issues/1078)) +- Respect .gitignore, .ignore, and .fdignore files when scanning package resources for skills, prompts, themes, and extensions ([#1072](https://github.com/badlogic/pi-mono/issues/1072)) ## [0.50.3] - 2026-01-29 diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 7c47f212..cf4da041 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -49,6 +49,7 @@ "diff": "^8.0.2", "file-type": "^21.1.1", "glob": "^11.0.3", + "ignore": "^7.0.5", "marked": "^15.0.12", "minimatch": "^10.1.1", "proper-lockfile": "^4.1.2", diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index ebb9be04..ab94e3df 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -2,7 +2,8 @@ import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; -import { basename, dirname, join, relative, resolve } from "node:path"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; +import ignore from "ignore"; import { minimatch } from "minimatch"; import { CONFIG_DIR_NAME } from "../config.js"; import { looksLikeGitUrl } from "../utils/git.js"; @@ -115,6 +116,57 @@ const FILE_PATTERNS: Record = { themes: /\.json$/, }; +const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"]; + +type IgnoreMatcher = ReturnType; + +function toPosixPath(p: string): string { + return p.split(sep).join("/"); +} + +function prefixIgnorePattern(line: string, prefix: string): string | null { + const trimmed = line.trim(); + if (!trimmed) return null; + if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) return null; + + let pattern = line; + let negated = false; + + if (pattern.startsWith("!")) { + negated = true; + pattern = pattern.slice(1); + } else if (pattern.startsWith("\\!")) { + pattern = pattern.slice(1); + } + + if (pattern.startsWith("/")) { + pattern = pattern.slice(1); + } + + const prefixed = prefix ? `${prefix}${pattern}` : pattern; + return negated ? `!${prefixed}` : prefixed; +} + +function addIgnoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): void { + const relativeDir = relative(rootDir, dir); + const prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : ""; + + for (const filename of IGNORE_FILE_NAMES) { + const ignorePath = join(dir, filename); + if (!existsSync(ignorePath)) continue; + try { + const content = readFileSync(ignorePath, "utf-8"); + const patterns = content + .split(/\r?\n/) + .map((line) => prefixIgnorePattern(line, prefix)) + .filter((line): line is string => Boolean(line)); + if (patterns.length > 0) { + ig.add(patterns); + } + } catch {} + } +} + function isPattern(s: string): boolean { return s.startsWith("!") || s.startsWith("+") || s.startsWith("-") || s.includes("*") || s.includes("?"); } @@ -132,10 +184,20 @@ function splitPatterns(entries: string[]): { plain: string[]; patterns: string[] return { plain, patterns }; } -function collectFiles(dir: string, filePattern: RegExp, skipNodeModules = true): string[] { +function collectFiles( + dir: string, + filePattern: RegExp, + skipNodeModules = true, + ignoreMatcher?: IgnoreMatcher, + rootDir?: string, +): string[] { const files: string[] = []; if (!existsSync(dir)) return files; + const root = rootDir ?? dir; + const ig = ignoreMatcher ?? ignore(); + addIgnoreRules(ig, dir, root); + try { const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { @@ -156,8 +218,12 @@ function collectFiles(dir: string, filePattern: RegExp, skipNodeModules = true): } } + const relPath = toPosixPath(relative(root, fullPath)); + const ignorePath = isDir ? `${relPath}/` : relPath; + if (ig.ignores(ignorePath)) continue; + if (isDir) { - files.push(...collectFiles(fullPath, filePattern, skipNodeModules)); + files.push(...collectFiles(fullPath, filePattern, skipNodeModules, ig, root)); } else if (isFile && filePattern.test(entry.name)) { files.push(fullPath); } @@ -169,10 +235,19 @@ function collectFiles(dir: string, filePattern: RegExp, skipNodeModules = true): return files; } -function collectSkillEntries(dir: string, includeRootFiles = true): string[] { +function collectSkillEntries( + dir: string, + includeRootFiles = true, + ignoreMatcher?: IgnoreMatcher, + rootDir?: string, +): string[] { const entries: string[] = []; if (!existsSync(dir)) return entries; + const root = rootDir ?? dir; + const ig = ignoreMatcher ?? ignore(); + addIgnoreRules(ig, dir, root); + try { const dirEntries = readdirSync(dir, { withFileTypes: true }); for (const entry of dirEntries) { @@ -193,8 +268,12 @@ function collectSkillEntries(dir: string, includeRootFiles = true): string[] { } } + const relPath = toPosixPath(relative(root, fullPath)); + const ignorePath = isDir ? `${relPath}/` : relPath; + if (ig.ignores(ignorePath)) continue; + if (isDir) { - entries.push(...collectSkillEntries(fullPath, false)); + entries.push(...collectSkillEntries(fullPath, false, ig, root)); } else if (isFile) { const isRootMd = includeRootFiles && entry.name.endsWith(".md"); const isSkillMd = !includeRootFiles && entry.name === "SKILL.md"; @@ -218,6 +297,9 @@ function collectAutoPromptEntries(dir: string): string[] { const entries: string[] = []; if (!existsSync(dir)) return entries; + const ig = ignore(); + addIgnoreRules(ig, dir, dir); + try { const dirEntries = readdirSync(dir, { withFileTypes: true }); for (const entry of dirEntries) { @@ -234,6 +316,9 @@ function collectAutoPromptEntries(dir: string): string[] { } } + const relPath = toPosixPath(relative(dir, fullPath)); + if (ig.ignores(relPath)) continue; + if (isFile && entry.name.endsWith(".md")) { entries.push(fullPath); } @@ -249,6 +334,9 @@ function collectAutoThemeEntries(dir: string): string[] { const entries: string[] = []; if (!existsSync(dir)) return entries; + const ig = ignore(); + addIgnoreRules(ig, dir, dir); + try { const dirEntries = readdirSync(dir, { withFileTypes: true }); for (const entry of dirEntries) { @@ -265,6 +353,9 @@ function collectAutoThemeEntries(dir: string): string[] { } } + const relPath = toPosixPath(relative(dir, fullPath)); + if (ig.ignores(relPath)) continue; + if (isFile && entry.name.endsWith(".json")) { entries.push(fullPath); } @@ -320,6 +411,9 @@ function collectAutoExtensionEntries(dir: string): string[] { const entries: string[] = []; if (!existsSync(dir)) return entries; + const ig = ignore(); + addIgnoreRules(ig, dir, dir); + try { const dirEntries = readdirSync(dir, { withFileTypes: true }); for (const entry of dirEntries) { @@ -340,6 +434,10 @@ function collectAutoExtensionEntries(dir: string): string[] { } } + const relPath = toPosixPath(relative(dir, fullPath)); + const ignorePath = isDir ? `${relPath}/` : relPath; + if (ig.ignores(ignorePath)) continue; + if (isFile && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) { entries.push(fullPath); } else if (isDir) { diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index ca3c4b31..3b6d99f0 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -113,6 +113,40 @@ Content`, }); }); + describe("ignore files", () => { + it("should respect .gitignore in skill directories", async () => { + const skillsDir = join(agentDir, "skills"); + mkdirSync(skillsDir, { recursive: true }); + writeFileSync(join(skillsDir, ".gitignore"), "venv\n__pycache__\n"); + + const goodSkillDir = join(skillsDir, "good-skill"); + mkdirSync(goodSkillDir, { recursive: true }); + writeFileSync(join(goodSkillDir, "SKILL.md"), "---\nname: good-skill\ndescription: Good\n---\nContent"); + + const ignoredSkillDir = join(skillsDir, "venv", "bad-skill"); + mkdirSync(ignoredSkillDir, { recursive: true }); + writeFileSync(join(ignoredSkillDir, "SKILL.md"), "---\nname: bad-skill\ndescription: Bad\n---\nContent"); + + settingsManager.setSkillPaths(["skills"]); + + const result = await packageManager.resolve(); + expect(result.skills.some((r) => r.path.includes("good-skill") && r.enabled)).toBe(true); + expect(result.skills.some((r) => r.path.includes("venv") && r.enabled)).toBe(false); + }); + + it("should not apply parent .gitignore to .pi auto-discovery", async () => { + writeFileSync(join(tempDir, ".gitignore"), ".pi\n"); + + const skillDir = join(tempDir, ".pi", "skills", "auto-skill"); + mkdirSync(skillDir, { recursive: true }); + const skillPath = join(skillDir, "SKILL.md"); + writeFileSync(skillPath, "---\nname: auto-skill\ndescription: Auto\n---\nContent"); + + const result = await packageManager.resolve(); + expect(result.skills.some((r) => r.path === skillPath && r.enabled)).toBe(true); + }); + }); + describe("resolveExtensionSources", () => { it("should resolve local paths", async () => { const extPath = join(tempDir, "ext.ts");