|
| 1 | +import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; |
| 2 | +import { readdir } from "node:fs/promises"; |
| 3 | +import { homedir } from "node:os"; |
| 4 | +import * as path from "node:path"; |
| 5 | + |
| 6 | +export interface AddedDir { |
| 7 | + absolutePath: string; |
| 8 | + label: string; |
| 9 | + addedAt: number; |
| 10 | +} |
| 11 | + |
| 12 | +export interface DirContext { |
| 13 | + dir: string; |
| 14 | + agentsMd: string | null; |
| 15 | + claudeMd: string | null; |
| 16 | + skillPaths: Map<string, string>; |
| 17 | + skills: Map<string, string>; |
| 18 | +} |
| 19 | + |
| 20 | +const CONTEXT_FILES = ["AGENTS.md", "CLAUDE.md"] as const; |
| 21 | +const SKILL_DIRS = [".pi/skills", ".agents/skills", ".claude/skills"] as const; |
| 22 | +const SKIPPED_SEARCH_DIRS = new Set([".git", "node_modules"]); |
| 23 | + |
| 24 | +export function expandUserPath(input: string): string { |
| 25 | + if (input === "~") return homedir(); |
| 26 | + if (input.startsWith("~/") || input.startsWith(`~${path.sep}`)) return path.join(homedir(), input.slice(2)); |
| 27 | + return input; |
| 28 | +} |
| 29 | + |
| 30 | +export function resolveDir(input: string, cwd: string): string { |
| 31 | + const expanded = expandUserPath(input); |
| 32 | + const resolved = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded); |
| 33 | + try { |
| 34 | + return realpathSync(resolved); |
| 35 | + } catch { |
| 36 | + return path.resolve(resolved); |
| 37 | + } |
| 38 | +} |
| 39 | + |
| 40 | +export function dirExists(dir: string): boolean { |
| 41 | + try { |
| 42 | + return statSync(dir).isDirectory(); |
| 43 | + } catch { |
| 44 | + return false; |
| 45 | + } |
| 46 | +} |
| 47 | + |
| 48 | +export function readFileSafe(filePath: string): string | null { |
| 49 | + try { |
| 50 | + return readFileSync(filePath, "utf8"); |
| 51 | + } catch { |
| 52 | + return null; |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +function readContextFile(dir: string, name: (typeof CONTEXT_FILES)[number]): string | null { |
| 57 | + const contents = [readFileSafe(path.join(dir, name)), readFileSafe(path.join(dir, ".pi", name))].filter( |
| 58 | + (content): content is string => content !== null, |
| 59 | + ); |
| 60 | + return contents.length > 0 ? contents.join("\n\n") : null; |
| 61 | +} |
| 62 | + |
| 63 | +function skillFiles(dir: string): Array<{ name: string; path: string }> { |
| 64 | + const files: Array<{ name: string; path: string }> = []; |
| 65 | + const names = new Set<string>(); |
| 66 | + |
| 67 | + for (const skillDir of SKILL_DIRS) { |
| 68 | + const fullSkillDir = path.join(dir, skillDir); |
| 69 | + if (!dirExists(fullSkillDir)) continue; |
| 70 | + try { |
| 71 | + for (const entry of readdirSync(fullSkillDir, { withFileTypes: true })) { |
| 72 | + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; |
| 73 | + const skillPath = path.join(fullSkillDir, entry.name, "SKILL.md"); |
| 74 | + try { |
| 75 | + if (statSync(skillPath).isFile() && !names.has(entry.name)) { |
| 76 | + names.add(entry.name); |
| 77 | + files.push({ name: entry.name, path: skillPath }); |
| 78 | + } |
| 79 | + } catch { |
| 80 | + // Skill may disappear while resources are being discovered. |
| 81 | + } |
| 82 | + } |
| 83 | + } catch { |
| 84 | + // Skip unreadable skill directories. |
| 85 | + } |
| 86 | + } |
| 87 | + |
| 88 | + return files; |
| 89 | +} |
| 90 | + |
| 91 | +export function scanDirContext(dir: string): DirContext { |
| 92 | + const ctx: DirContext = { |
| 93 | + dir, |
| 94 | + agentsMd: readContextFile(dir, "AGENTS.md"), |
| 95 | + claudeMd: readContextFile(dir, "CLAUDE.md"), |
| 96 | + skillPaths: new Map(), |
| 97 | + skills: new Map(), |
| 98 | + }; |
| 99 | + |
| 100 | + for (const skill of skillFiles(dir)) { |
| 101 | + const content = readFileSafe(skill.path); |
| 102 | + if (content === null) continue; |
| 103 | + ctx.skillPaths.set(skill.name, skill.path); |
| 104 | + ctx.skills.set(skill.name, content); |
| 105 | + } |
| 106 | + |
| 107 | + return ctx; |
| 108 | +} |
| 109 | + |
| 110 | +export function collectSkillPaths(dirs: AddedDir[]): string[] { |
| 111 | + const paths: string[] = []; |
| 112 | + const names = new Set<string>(); |
| 113 | + for (const dir of dirs) { |
| 114 | + if (!dirExists(dir.absolutePath)) continue; |
| 115 | + for (const skill of skillFiles(dir.absolutePath)) { |
| 116 | + if (names.has(skill.name)) continue; |
| 117 | + names.add(skill.name); |
| 118 | + paths.push(skill.path); |
| 119 | + } |
| 120 | + } |
| 121 | + return paths; |
| 122 | +} |
| 123 | + |
| 124 | +function skillDescription(content: string): string { |
| 125 | + const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1]; |
| 126 | + const value = frontmatter?.match(/^description:\s*(.*?)\s*$/m)?.[1]; |
| 127 | + if (!value || value === ">" || value === "|") return "No description"; |
| 128 | + return value.replace(/^("|')|("|')$/g, "").trim() || "No description"; |
| 129 | +} |
| 130 | + |
| 131 | +export function buildContextInjection(dirs: AddedDir[]): string { |
| 132 | + if (dirs.length === 0) return ""; |
| 133 | + |
| 134 | + const sections = [ |
| 135 | + "\n\n## External Directories (added via pi-add-dir)", |
| 136 | + `\nThe following ${dirs.length} external director${dirs.length === 1 ? "y is" : "ies are"} included in this session. You can read, edit, and write files in these directories using absolute paths.\n`, |
| 137 | + ]; |
| 138 | + |
| 139 | + const registeredSkills = new Set<string>(); |
| 140 | + for (const dir of dirs) { |
| 141 | + const ctx = scanDirContext(dir.absolutePath); |
| 142 | + const skills = [...ctx.skills].filter(([name]) => { |
| 143 | + if (registeredSkills.has(name)) return false; |
| 144 | + registeredSkills.add(name); |
| 145 | + return true; |
| 146 | + }); |
| 147 | + sections.push(`### ${dir.label} - \`${dir.absolutePath}\``); |
| 148 | + |
| 149 | + if (ctx.agentsMd) sections.push(`\n#### AGENTS.md (from ${dir.label})\n${ctx.agentsMd}`); |
| 150 | + if (ctx.claudeMd) sections.push(`\n#### CLAUDE.md (from ${dir.label})\n${ctx.claudeMd}`); |
| 151 | + |
| 152 | + if (skills.length > 0) { |
| 153 | + sections.push(`\n#### Skills from ${dir.label} (registered as /skill:name commands):`); |
| 154 | + for (const [name, content] of skills) { |
| 155 | + sections.push( |
| 156 | + `- **${name}**: ${skillDescription(content)} - use \`/skill:${name}\` or read \`${ctx.skillPaths.get(name)}\``, |
| 157 | + ); |
| 158 | + } |
| 159 | + } |
| 160 | + } |
| 161 | + |
| 162 | + return sections.join("\n"); |
| 163 | +} |
| 164 | + |
| 165 | +function normalizePattern(pattern: string): string { |
| 166 | + let normalized = pattern.trim(); |
| 167 | + if (path.sep === "\\") normalized = normalized.replaceAll("/", "\\"); |
| 168 | + const prefix = `.${path.sep}`; |
| 169 | + if (normalized.startsWith(prefix)) normalized = normalized.slice(prefix.length); |
| 170 | + return normalized; |
| 171 | +} |
| 172 | + |
| 173 | +export async function findFiles( |
| 174 | + root: string, |
| 175 | + pattern: string, |
| 176 | + maxResults: number, |
| 177 | + signal?: AbortSignal, |
| 178 | +): Promise<string[]> { |
| 179 | + const normalizedPattern = normalizePattern(pattern); |
| 180 | + if (!normalizedPattern) throw new Error("File pattern must not be blank."); |
| 181 | + |
| 182 | + const matchPath = normalizedPattern.includes(path.sep); |
| 183 | + const results: string[] = []; |
| 184 | + const pending = [root]; |
| 185 | + |
| 186 | + signal?.throwIfAborted(); |
| 187 | + while (pending.length > 0) { |
| 188 | + signal?.throwIfAborted(); |
| 189 | + const current = pending.pop()!; |
| 190 | + let entries; |
| 191 | + try { |
| 192 | + entries = await readdir(current, { withFileTypes: true }); |
| 193 | + } catch { |
| 194 | + continue; |
| 195 | + } |
| 196 | + |
| 197 | + for (const entry of entries) { |
| 198 | + signal?.throwIfAborted(); |
| 199 | + const fullPath = path.join(current, entry.name); |
| 200 | + if (entry.isDirectory()) { |
| 201 | + if (!SKIPPED_SEARCH_DIRS.has(entry.name)) pending.push(fullPath); |
| 202 | + continue; |
| 203 | + } |
| 204 | + if (!entry.isFile()) continue; |
| 205 | + |
| 206 | + const candidate = matchPath ? path.relative(root, fullPath) : entry.name; |
| 207 | + if (!path.matchesGlob(candidate, normalizedPattern)) continue; |
| 208 | + results.push(fullPath); |
| 209 | + if (results.length >= maxResults) return results; |
| 210 | + } |
| 211 | + } |
| 212 | + |
| 213 | + return results; |
| 214 | +} |
0 commit comments