@shelken/pi-add-dir 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -0
  3. package/helpers.ts +206 -0
  4. package/index.ts +485 -0
  5. package/package.json +49 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 itisbryan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # pi-add-dir
2
+
3
+ 将会话外目录加入当前 pi session:加载 `AGENTS.md` / `CLAUDE.md`、发现 skills,并跨重启持久化。
4
+
5
+ 初始 fork 自 [itisbryan/pi-add-dir](https://github.com/itisbryan/pi-add-dir);本仓库已大幅简化(去掉 smart suggestions / monorepo 推荐 / 扩展探测等)。
6
+
7
+ ## 能力
8
+
9
+ | 类型 | 名称 | 说明 |
10
+ |---|---|---|
11
+ | 命令 | `/add-dir [path]` | 添加目录;无参时交互输入路径 |
12
+ | 命令 | `/remove-dir [path\|label]` | 移除目录 |
13
+ | 命令 | `/dirs` | 列出已添加目录 |
14
+ | 工具 | `add_directory` | 让 agent 请求添加目录 |
15
+ | 工具 | `search_external_files` | 在外部目录中按文件名/glob 搜索 |
16
+
17
+ Skills 通过 `resources_discover` 注册为 `/skill:name`。
18
+
19
+ ## 安装
20
+
21
+ 从 npm 安装:
22
+
23
+ ```bash
24
+ pi install npm:@shelken/pi-add-dir
25
+ ```
26
+
27
+ 本 monorepo 根 `package.json` 的 `pi.extensions` 也已包含入口。本地使用时可直接引用整个仓库:
28
+
29
+ ```json
30
+ {
31
+ "packages": ["/path/to/pi-extensions"]
32
+ }
33
+ ```
34
+
35
+ ## 验证
36
+
37
+ ```bash
38
+ bun --filter pi-add-dir test
39
+ ```
package/helpers.ts ADDED
@@ -0,0 +1,206 @@
1
+ import * as crypto from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as os 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
+
22
+ const SKILL_DIRS = [".pi/skills", ".agents/skills", ".claude/skills"] as const;
23
+
24
+ export function expandUserPath(input: string): string {
25
+ if (input === "~") return os.homedir();
26
+ if (input.startsWith("~/")) return path.join(os.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 fs.realpathSync(resolved);
35
+ } catch {
36
+ return path.resolve(resolved);
37
+ }
38
+ }
39
+
40
+ export function dirExists(dir: string): boolean {
41
+ try {
42
+ return fs.statSync(dir).isDirectory();
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ export function readFileSafe(filePath: string): string | null {
49
+ try {
50
+ return fs.readFileSync(filePath, "utf-8");
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export function cwdHash(cwd: string): string {
57
+ return crypto.createHash("sha256").update(cwd).digest("hex").slice(0, 12);
58
+ }
59
+
60
+ export function getTempStatePath(cwd: string): string {
61
+ return path.join(os.tmpdir(), `pi-add-dir-${cwdHash(cwd)}.json`);
62
+ }
63
+
64
+ export function writeTempState(cwd: string, dirs: AddedDir[]): void {
65
+ try {
66
+ fs.writeFileSync(getTempStatePath(cwd), JSON.stringify({ dirs }), "utf-8");
67
+ } catch {
68
+ // temp state is only a bridge for resources_discover
69
+ }
70
+ }
71
+
72
+ export function readTempState(cwd: string): AddedDir[] {
73
+ try {
74
+ const content = fs.readFileSync(getTempStatePath(cwd), "utf-8");
75
+ const data = JSON.parse(content) as { dirs?: AddedDir[] };
76
+ return data.dirs ?? [];
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+
82
+ export function removeTempState(cwd: string): void {
83
+ try {
84
+ fs.unlinkSync(getTempStatePath(cwd));
85
+ } catch {
86
+ // already gone
87
+ }
88
+ }
89
+
90
+ export function scanDirContext(dir: string): DirContext {
91
+ const ctx: DirContext = {
92
+ dir,
93
+ agentsMd: null,
94
+ claudeMd: null,
95
+ skillPaths: new Map(),
96
+ skills: new Map(),
97
+ };
98
+
99
+ for (const name of CONTEXT_FILES) {
100
+ const content = readFileSafe(path.join(dir, name));
101
+ if (name === "AGENTS.md") ctx.agentsMd = content;
102
+ if (name === "CLAUDE.md") ctx.claudeMd = content;
103
+ }
104
+
105
+ for (const name of CONTEXT_FILES) {
106
+ const piContent = readFileSafe(path.join(dir, ".pi", name));
107
+ if (!piContent) continue;
108
+ if (name === "AGENTS.md") ctx.agentsMd = (ctx.agentsMd ?? "") + "\n\n" + piContent;
109
+ if (name === "CLAUDE.md") ctx.claudeMd = (ctx.claudeMd ?? "") + "\n\n" + piContent;
110
+ }
111
+
112
+ for (const skillDir of SKILL_DIRS) {
113
+ const fullSkillDir = path.join(dir, skillDir);
114
+ if (!dirExists(fullSkillDir)) continue;
115
+ try {
116
+ for (const entry of fs.readdirSync(fullSkillDir, { withFileTypes: true })) {
117
+ if (!entry.isDirectory()) continue;
118
+ const skillMdPath = path.join(fullSkillDir, entry.name, "SKILL.md");
119
+ const skillMd = readFileSafe(skillMdPath);
120
+ if (skillMd) {
121
+ ctx.skillPaths.set(entry.name, skillMdPath);
122
+ ctx.skills.set(entry.name, skillMd);
123
+ }
124
+ }
125
+ } catch {
126
+ // skip unreadable skill dirs
127
+ }
128
+ }
129
+
130
+ return ctx;
131
+ }
132
+
133
+ export function collectSkillPaths(dirs: AddedDir[]): string[] {
134
+ const skillPaths: string[] = [];
135
+ for (const dir of dirs) {
136
+ if (!dirExists(dir.absolutePath)) continue;
137
+ for (const skillDir of SKILL_DIRS) {
138
+ const fullSkillDir = path.join(dir.absolutePath, skillDir);
139
+ if (!dirExists(fullSkillDir)) continue;
140
+ try {
141
+ for (const entry of fs.readdirSync(fullSkillDir, { withFileTypes: true })) {
142
+ if (!entry.isDirectory()) continue;
143
+ const skillMdPath = path.join(fullSkillDir, entry.name, "SKILL.md");
144
+ if (readFileSafe(skillMdPath) !== null) {
145
+ skillPaths.push(skillMdPath);
146
+ }
147
+ }
148
+ } catch {
149
+ // skip unreadable skill dirs
150
+ }
151
+ }
152
+ }
153
+ return skillPaths;
154
+ }
155
+
156
+ let contextCache: { dirs: string; injection: string } | null = null;
157
+
158
+ export function invalidateContextCache(): void {
159
+ contextCache = null;
160
+ }
161
+
162
+ export function buildContextInjection(dirs: AddedDir[]): string {
163
+ if (dirs.length === 0) return "";
164
+
165
+ const cacheKey = dirs
166
+ .map((d) => d.absolutePath)
167
+ .sort()
168
+ .join("\0");
169
+ if (contextCache && contextCache.dirs === cacheKey) {
170
+ return contextCache.injection;
171
+ }
172
+
173
+ const sections: string[] = [];
174
+ sections.push("\n\n## External Directories (added via pi-add-dir)");
175
+ sections.push(
176
+ `\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`,
177
+ );
178
+
179
+ for (const dir of dirs) {
180
+ const ctx = scanDirContext(dir.absolutePath);
181
+ sections.push(`### ${dir.label} - \`${dir.absolutePath}\``);
182
+
183
+ if (ctx.agentsMd) {
184
+ sections.push(`\n#### AGENTS.md (from ${dir.label})\n${ctx.agentsMd}`);
185
+ }
186
+ if (ctx.claudeMd) {
187
+ sections.push(`\n#### CLAUDE.md (from ${dir.label})\n${ctx.claudeMd}`);
188
+ }
189
+
190
+ if (ctx.skills.size > 0) {
191
+ sections.push(`\n#### Skills from ${dir.label} (registered as /skill:name commands):`);
192
+ for (const [name, content] of ctx.skills) {
193
+ const descMatch = content.match(/^---\n[\s\S]*?description:\s*>?\s*\n?\s*(.*?)(?:\n---|\n\w)/m);
194
+ const desc = descMatch?.[1]?.trim() ?? "No description";
195
+ sections.push(
196
+ `- **${name}**: ${desc} - use \`/skill:${name}\` or read \`${ctx.skillPaths.get(name)}\``,
197
+ );
198
+ }
199
+ }
200
+ // 顶层目录列表会随文件创建/删除变化,放入 system prompt 会破坏 prompt cache。
201
+ }
202
+
203
+ const injection = sections.join("\n");
204
+ contextCache = { dirs: cacheKey, injection };
205
+ return injection;
206
+ }
package/index.ts ADDED
@@ -0,0 +1,485 @@
1
+ /**
2
+ * pi-add-dir - 将会话外目录加入当前 pi session。
3
+ *
4
+ * 加载 AGENTS.md / CLAUDE.md,发现 skills,注入 system prompt,并跨重启持久化。
5
+ * 外部 skills 通过 resources_discover 注册为 /skill:name。
6
+ */
7
+
8
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
+ import { Type } from "typebox";
11
+ import * as childProcess from "node:child_process";
12
+ import * as path from "node:path";
13
+ import {
14
+ type AddedDir,
15
+ buildContextInjection,
16
+ collectSkillPaths,
17
+ dirExists,
18
+ invalidateContextCache,
19
+ readTempState,
20
+ removeTempState,
21
+ resolveDir,
22
+ scanDirContext,
23
+ writeTempState,
24
+ } from "./helpers.ts";
25
+
26
+ export default function addDirExtension(pi: ExtensionAPI): void {
27
+ let addedDirs: AddedDir[] = [];
28
+ let currentCwd = "";
29
+
30
+ pi.on("resources_discover", (event, _ctx) => {
31
+ const dirs = readTempState(event.cwd);
32
+ if (dirs.length === 0) return;
33
+ const skillPaths = collectSkillPaths(dirs);
34
+ if (skillPaths.length === 0) return;
35
+ return { skillPaths };
36
+ });
37
+
38
+ function reconstructState(ctx: ExtensionContext): void {
39
+ addedDirs = [];
40
+ currentCwd = ctx.cwd;
41
+
42
+ for (const entry of ctx.sessionManager.getBranch()) {
43
+ if (entry.type !== "custom") continue;
44
+ if (entry.customType === "add-dir:state") {
45
+ addedDirs = (entry.data as { dirs: AddedDir[] })?.dirs ?? [];
46
+ }
47
+ }
48
+
49
+ invalidateContextCache();
50
+ writeTempState(currentCwd, addedDirs);
51
+ updateWidget(ctx);
52
+ }
53
+
54
+ function persistState(cwd?: string): void {
55
+ pi.appendEntry("add-dir:state", { dirs: addedDirs });
56
+ const effectiveCwd = cwd || currentCwd;
57
+ if (effectiveCwd) {
58
+ writeTempState(effectiveCwd, addedDirs);
59
+ }
60
+ }
61
+
62
+ function updateWidget(ctx: ExtensionContext): void {
63
+ if (!ctx.hasUI) return;
64
+
65
+ if (addedDirs.length === 0) {
66
+ ctx.ui.setWidget("add-dir", undefined);
67
+ return;
68
+ }
69
+
70
+ ctx.ui.setWidget("add-dir", (_tui, theme) => {
71
+ return {
72
+ dispose() {},
73
+ invalidate() {},
74
+ render(width: number): string[] {
75
+ const prefix = theme.fg("accent", "📂");
76
+ const count = theme.fg(
77
+ "muted",
78
+ ` ${addedDirs.length} external dir${addedDirs.length === 1 ? "" : "s"}`,
79
+ );
80
+ const sep = theme.fg("dim", " | ");
81
+ const suffix = theme.fg("dim", " (/dirs to manage)");
82
+ const dirLabels = addedDirs.map((d) => theme.fg("text", d.label)).join(theme.fg("dim", ", "));
83
+ const fullLine = ` ${prefix}${count}${sep}${dirLabels}${suffix}`;
84
+
85
+ if (visibleWidth(fullLine) <= width) {
86
+ return [fullLine];
87
+ }
88
+
89
+ const withoutLabels = ` ${prefix}${count}${sep}`;
90
+ const available = width - visibleWidth(withoutLabels) - visibleWidth(suffix);
91
+ if (available > 5) {
92
+ return [`${withoutLabels}${truncateToWidth(dirLabels, available, "…")}${suffix}`];
93
+ }
94
+ return [truncateToWidth(` ${prefix}${count}`, width, "…")];
95
+ },
96
+ };
97
+ });
98
+ }
99
+
100
+ function addDir(
101
+ dirPath: string,
102
+ cwd: string,
103
+ ctx: ExtensionContext,
104
+ ): { ok: boolean; message: string; hasNewSkills: boolean } {
105
+ const absolutePath = resolveDir(dirPath, cwd);
106
+
107
+ if (!dirExists(absolutePath)) {
108
+ return { ok: false, message: `Directory does not exist: ${absolutePath}`, hasNewSkills: false };
109
+ }
110
+ if (addedDirs.some((d) => d.absolutePath === absolutePath)) {
111
+ return { ok: false, message: `Already added: ${absolutePath}`, hasNewSkills: false };
112
+ }
113
+ if (absolutePath === resolveDir(cwd, cwd)) {
114
+ return {
115
+ ok: false,
116
+ message: "That's the current working directory - already in scope.",
117
+ hasNewSkills: false,
118
+ };
119
+ }
120
+
121
+ const label = path.basename(absolutePath);
122
+ addedDirs.push({ absolutePath, label, addedAt: Date.now() });
123
+ invalidateContextCache();
124
+ persistState(cwd);
125
+ updateWidget(ctx);
126
+
127
+ const dirCtx = scanDirContext(absolutePath);
128
+ const found: string[] = [];
129
+ if (dirCtx.agentsMd) found.push("AGENTS.md");
130
+ if (dirCtx.claudeMd) found.push("CLAUDE.md");
131
+ if (dirCtx.skills.size > 0) found.push(`${dirCtx.skills.size} skill(s)`);
132
+
133
+ const hasNewSkills = dirCtx.skills.size > 0;
134
+ const foundStr = found.length > 0 ? ` Found: ${found.join(", ")}.` : " No context files found.";
135
+ let message = `Added ${label} (${absolutePath}).${foundStr}`;
136
+ if (hasNewSkills) {
137
+ message += " Reloading to register skills as /skill:name commands...";
138
+ }
139
+ return { ok: true, message, hasNewSkills };
140
+ }
141
+
142
+ function removeDir(
143
+ absolutePath: string,
144
+ ctx: ExtensionContext,
145
+ ): { ok: boolean; message: string; hadSkills: boolean } {
146
+ const idx = addedDirs.findIndex((d) => d.absolutePath === absolutePath);
147
+ if (idx === -1) {
148
+ return { ok: false, message: `Not found: ${absolutePath}`, hadSkills: false };
149
+ }
150
+
151
+ const hadSkills = scanDirContext(absolutePath).skills.size > 0;
152
+ const removed = addedDirs.splice(idx, 1)[0];
153
+ invalidateContextCache();
154
+ persistState();
155
+ updateWidget(ctx);
156
+
157
+ let message = `Removed ${removed.label} (${removed.absolutePath}).`;
158
+ if (hadSkills) {
159
+ message += " Reloading to unregister skills...";
160
+ }
161
+ return { ok: true, message, hadSkills };
162
+ }
163
+
164
+ pi.on("session_start", async (_e, ctx) => reconstructState(ctx));
165
+ pi.on("session_tree", async (_e, ctx) => reconstructState(ctx));
166
+ pi.on("session_shutdown", async () => {
167
+ if (currentCwd) removeTempState(currentCwd);
168
+ });
169
+
170
+ pi.on("before_agent_start", async (event, _ctx) => {
171
+ if (addedDirs.length === 0) return;
172
+ return {
173
+ systemPrompt: event.systemPrompt + buildContextInjection(addedDirs),
174
+ };
175
+ });
176
+
177
+ pi.registerCommand("add-dir", {
178
+ description: "Add an external directory to this session",
179
+ handler: async (args, ctx) => {
180
+ let inputPath = args?.trim();
181
+ if (!inputPath) {
182
+ const prompted = await ctx.ui.input("Directory path:", "");
183
+ if (!prompted) return;
184
+ inputPath = prompted;
185
+ }
186
+
187
+ const result = addDir(inputPath, ctx.cwd, ctx);
188
+ ctx.ui.notify(result.message, result.ok ? "info" : "error");
189
+ if (result.ok && result.hasNewSkills) {
190
+ await ctx.reload();
191
+ }
192
+ },
193
+ });
194
+
195
+ pi.registerCommand("remove-dir", {
196
+ description: "Remove an external directory from this session",
197
+ getArgumentCompletions(prefix: string) {
198
+ if (addedDirs.length === 0) return null;
199
+ const lower = prefix.toLowerCase();
200
+ return addedDirs
201
+ .filter(
202
+ (d) =>
203
+ d.label.toLowerCase().startsWith(lower) || d.absolutePath.toLowerCase().startsWith(lower),
204
+ )
205
+ .map((d) => ({ label: d.label, value: d.absolutePath, description: d.absolutePath }));
206
+ },
207
+ handler: async (args, ctx) => {
208
+ if (addedDirs.length === 0) {
209
+ ctx.ui.notify("No external directories added.", "info");
210
+ return;
211
+ }
212
+
213
+ let absolutePath: string | undefined;
214
+ if (args?.trim()) {
215
+ const input = args.trim();
216
+ const byLabel = addedDirs.find((d) => d.label === input);
217
+ absolutePath = byLabel ? byLabel.absolutePath : resolveDir(input, ctx.cwd);
218
+ } else {
219
+ const choices = addedDirs.map((d) => `${d.label} - ${d.absolutePath}`);
220
+ const selected = await ctx.ui.select("Remove which directory?", choices);
221
+ if (selected === undefined) return;
222
+ const selectedIdx = choices.indexOf(selected);
223
+ absolutePath = selectedIdx >= 0 ? addedDirs[selectedIdx]?.absolutePath : undefined;
224
+ }
225
+
226
+ if (!absolutePath) return;
227
+ const result = removeDir(absolutePath, ctx);
228
+ ctx.ui.notify(result.message, result.ok ? "info" : "error");
229
+ if (result.ok && result.hadSkills) {
230
+ await ctx.reload();
231
+ }
232
+ },
233
+ });
234
+
235
+ pi.registerCommand("dirs", {
236
+ description: "List all external directories in this session",
237
+ handler: async (_args, ctx) => {
238
+ if (addedDirs.length === 0) {
239
+ ctx.ui.notify("No external directories added. Use /add-dir <path> to add one.", "info");
240
+ return;
241
+ }
242
+
243
+ const lines: string[] = [`External directories (${addedDirs.length}):\n`];
244
+ for (const dir of addedDirs) {
245
+ const dirCtx = scanDirContext(dir.absolutePath);
246
+ const badges: string[] = [];
247
+ if (dirCtx.agentsMd) badges.push("AGENTS.md");
248
+ if (dirCtx.claudeMd) badges.push("CLAUDE.md");
249
+ if (dirCtx.skills.size > 0) badges.push(`${dirCtx.skills.size} skill(s)`);
250
+
251
+ lines.push(` 📂 ${dir.label}`);
252
+ lines.push(` ${dir.absolutePath}`);
253
+ if (badges.length > 0) lines.push(` Found: ${badges.join(", ")}`);
254
+ if (dirCtx.skills.size > 0) {
255
+ lines.push(` Skills: ${[...dirCtx.skills.keys()].map((s) => `/skill:${s}`).join(", ")}`);
256
+ }
257
+ lines.push("");
258
+ }
259
+ ctx.ui.notify(lines.join("\n"), "info");
260
+ },
261
+ });
262
+
263
+ pi.registerTool({
264
+ name: "add_directory",
265
+ label: "Add Directory",
266
+ description:
267
+ "Add an external directory to this session so its AGENTS.md, CLAUDE.md, and skills are loaded into context. " +
268
+ "Use this when you need to reference or work with code in a directory outside the current working directory.",
269
+ promptSnippet: "Add an external directory to this session (loads its AGENTS.md, skills, etc.)",
270
+ promptGuidelines: [
271
+ "Use add_directory when you need context from another project or directory outside cwd.",
272
+ "The directory's AGENTS.md and CLAUDE.md are injected into the system prompt automatically.",
273
+ "After adding, you can read/edit/write files in that directory using absolute paths.",
274
+ ],
275
+ parameters: Type.Object({
276
+ path: Type.String({
277
+ description: "Absolute or relative path to the directory to add",
278
+ }),
279
+ reason: Type.Optional(
280
+ Type.String({
281
+ description: "Why this directory is being added (shown to user)",
282
+ }),
283
+ ),
284
+ }),
285
+
286
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
287
+ const dirPath = params.path.replace(/^@/, "");
288
+ const result = addDir(dirPath, ctx.cwd, ctx);
289
+ if (!result.ok) throw new Error(result.message);
290
+
291
+ const absolutePath = resolveDir(dirPath, ctx.cwd);
292
+ const dirCtx = scanDirContext(absolutePath);
293
+ const response: string[] = [result.message];
294
+ if (dirCtx.agentsMd) response.push("\nAGENTS.md content has been injected into system context.");
295
+ if (dirCtx.claudeMd) response.push("CLAUDE.md content has been injected into system context.");
296
+ if (dirCtx.skills.size > 0) {
297
+ response.push(`\nDiscovered skills: ${[...dirCtx.skills.keys()].join(", ")}`);
298
+ response.push("Skills will be registered as /skill:name commands after reload.");
299
+ }
300
+ response.push(`\nYou can now access files at: ${absolutePath}`);
301
+
302
+ return {
303
+ content: [{ type: "text", text: response.join("\n") }],
304
+ details: {
305
+ directory: absolutePath,
306
+ hasAgentsMd: !!dirCtx.agentsMd,
307
+ hasClaudeMd: !!dirCtx.claudeMd,
308
+ skillCount: dirCtx.skills.size,
309
+ skillNames: [...dirCtx.skills.keys()],
310
+ },
311
+ };
312
+ },
313
+
314
+ renderCall(args, theme, _context) {
315
+ const dirPath = args.path?.replace(/^@/, "") ?? "";
316
+ let text = theme.fg("toolTitle", theme.bold("add_directory "));
317
+ text += theme.fg("accent", dirPath);
318
+ if (args.reason) text += theme.fg("dim", ` (${args.reason})`);
319
+ return new Text(text, 0, 0);
320
+ },
321
+
322
+ renderResult(result, { expanded }, theme, _context) {
323
+ const details = result.details as
324
+ | {
325
+ directory?: string;
326
+ hasAgentsMd?: boolean;
327
+ hasClaudeMd?: boolean;
328
+ skillCount?: number;
329
+ skillNames?: string[];
330
+ }
331
+ | undefined;
332
+
333
+ if (!details) {
334
+ const content = result.content?.[0];
335
+ const text = content && "text" in content ? content.text : "Done";
336
+ return new Text(theme.fg("success", `✓ ${text}`), 0, 0);
337
+ }
338
+
339
+ const parts: string[] = [];
340
+ parts.push(theme.fg("success", `✓ Added ${path.basename(details.directory ?? "")}`));
341
+
342
+ const badges: string[] = [];
343
+ if (details.hasAgentsMd) badges.push(theme.fg("accent", "AGENTS.md"));
344
+ if (details.hasClaudeMd) badges.push(theme.fg("accent", "CLAUDE.md"));
345
+ if (details.skillCount && details.skillCount > 0) {
346
+ badges.push(theme.fg("warning", `${details.skillCount} skills`));
347
+ }
348
+ if (badges.length > 0) {
349
+ parts.push(theme.fg("dim", " | ") + badges.join(theme.fg("dim", ", ")));
350
+ }
351
+ if (expanded && details.skillNames && details.skillNames.length > 0) {
352
+ parts.push(
353
+ "\n" +
354
+ theme.fg("muted", " Skills: ") +
355
+ details.skillNames.map((s) => theme.fg("text", s)).join(", "),
356
+ );
357
+ }
358
+ return new Text(parts.join(""), 0, 0);
359
+ },
360
+ });
361
+
362
+ pi.registerTool({
363
+ name: "search_external_files",
364
+ label: "Search External Files",
365
+ description:
366
+ "Search for files across all external directories added to this session. " +
367
+ "Use this when you need to find files in external directories, since the @ file picker " +
368
+ "only searches the current working directory.",
369
+ promptSnippet: "Search for files across all added external directories by name pattern",
370
+ promptGuidelines: [
371
+ "Use search_external_files when you need to find a file in an external directory but don't know its exact path.",
372
+ "Supports glob-style patterns like '*.ts', '**/*.test.js', 'src/**/*.rb'.",
373
+ "Returns matching file paths with their parent directory labels.",
374
+ ],
375
+ parameters: Type.Object({
376
+ pattern: Type.String({
377
+ description: "File name or glob pattern to search for (e.g., '*.ts', 'config/**', 'README.md')",
378
+ }),
379
+ maxResults: Type.Optional(
380
+ Type.Number({
381
+ description: "Maximum number of results to return (default: 50)",
382
+ }),
383
+ ),
384
+ }),
385
+
386
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
387
+ if (addedDirs.length === 0) {
388
+ throw new Error("No external directories added. Use /add-dir or add_directory first.");
389
+ }
390
+
391
+ const maxResults = params.maxResults ?? 50;
392
+ const pattern = params.pattern.replace(/^@/, "");
393
+ const results: { dir: string; label: string; files: string[] }[] = [];
394
+ let totalFound = 0;
395
+
396
+ for (const dir of addedDirs) {
397
+ if (signal?.aborted) break;
398
+ if (!dirExists(dir.absolutePath)) continue;
399
+
400
+ const remaining = maxResults - totalFound;
401
+ if (remaining <= 0) break;
402
+
403
+ const hasSlash = pattern.includes("/");
404
+ const findFlag = hasSlash ? "-path" : "-name";
405
+ const findArgs = [
406
+ dir.absolutePath,
407
+ "-not",
408
+ "-path",
409
+ "*/node_modules/*",
410
+ "-not",
411
+ "-path",
412
+ "*/.git/*",
413
+ findFlag,
414
+ pattern,
415
+ "-type",
416
+ "f",
417
+ ];
418
+ const result = childProcess.spawnSync("find", findArgs, {
419
+ encoding: "utf-8",
420
+ timeout: 10_000,
421
+ });
422
+ const output = (result.stdout ?? "").trim();
423
+ const files = (output ? output.split("\n").filter(Boolean) : []).slice(0, remaining);
424
+ if (files.length > 0) {
425
+ results.push({ dir: dir.absolutePath, label: dir.label, files });
426
+ totalFound += files.length;
427
+ }
428
+ }
429
+
430
+ if (totalFound === 0) {
431
+ return {
432
+ content: [
433
+ {
434
+ type: "text",
435
+ text: `No files matching "${pattern}" found in ${addedDirs.length} external director${addedDirs.length === 1 ? "y" : "ies"}.`,
436
+ },
437
+ ],
438
+ details: { totalFound: 0, pattern },
439
+ };
440
+ }
441
+
442
+ const lines: string[] = [`Found ${totalFound} file(s) matching "${pattern}":\n`];
443
+ for (const r of results) {
444
+ lines.push(`📂 ${r.label} (${r.dir}):`);
445
+ for (const f of r.files) lines.push(` ${f}`);
446
+ lines.push("");
447
+ }
448
+
449
+ return {
450
+ content: [{ type: "text", text: lines.join("\n") }],
451
+ details: { totalFound, pattern, dirCount: results.length },
452
+ };
453
+ },
454
+
455
+ renderCall(args, theme, _context) {
456
+ const pattern = args.pattern?.replace(/^@/, "") ?? "";
457
+ let text = theme.fg("toolTitle", theme.bold("search_external_files "));
458
+ text += theme.fg("accent", `"${pattern}"`);
459
+ text += theme.fg("dim", ` across ${addedDirs.length} dir(s)`);
460
+ return new Text(text, 0, 0);
461
+ },
462
+
463
+ renderResult(result, { expanded }, theme, _context) {
464
+ const details = result.details as
465
+ | { totalFound?: number; pattern?: string; dirCount?: number }
466
+ | undefined;
467
+
468
+ if (!details || !details.totalFound) {
469
+ const content = result.content?.[0];
470
+ const text = content && "text" in content ? content.text : "No results";
471
+ return new Text(theme.fg("muted", text), 0, 0);
472
+ }
473
+
474
+ let text = theme.fg("success", `✓ ${details.totalFound} file(s)`);
475
+ text += theme.fg("dim", ` matching "${details.pattern}" in ${details.dirCount} dir(s)`);
476
+ if (expanded) {
477
+ const content = result.content?.[0];
478
+ if (content && "text" in content) {
479
+ text += "\n" + theme.fg("muted", content.text);
480
+ }
481
+ }
482
+ return new Text(text, 0, 0);
483
+ },
484
+ });
485
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@shelken/pi-add-dir",
3
+ "version": "1.4.0",
4
+ "description": "Add external directories to a pi session - loads AGENTS.md, CLAUDE.md, and skills into context.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "files": [
8
+ "index.ts",
9
+ "helpers.ts"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/shelken/pi-extensions.git",
14
+ "directory": "extensions/pi-add-dir"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "scripts": {
20
+ "test": "vitest run"
21
+ },
22
+ "keywords": [
23
+ "pi-package",
24
+ "pi-extension",
25
+ "external-directory",
26
+ "context"
27
+ ],
28
+ "pi": {
29
+ "extensions": [
30
+ "./index.ts"
31
+ ]
32
+ },
33
+ "peerDependencies": {
34
+ "@earendil-works/pi-coding-agent": "*",
35
+ "@earendil-works/pi-tui": "*",
36
+ "typebox": "*"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "@earendil-works/pi-coding-agent": {
40
+ "optional": true
41
+ },
42
+ "@earendil-works/pi-tui": {
43
+ "optional": true
44
+ },
45
+ "typebox": {
46
+ "optional": true
47
+ }
48
+ }
49
+ }