@henryqw/pi-add-dir 0.1.9 → 0.1.11

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.
package/README.md CHANGED
@@ -19,6 +19,7 @@ pi install npm:@henryqw/pi-add-dir
19
19
  | --- | --- | --- |
20
20
  | `/dir-add` | command | Add directory; no path opens input. Supports `~`. |
21
21
  | `/dir-ls` | command | List directories; select one to remove. |
22
+ | `/dir-reload` | command | Reload external directory resources. |
22
23
  | `add_directory` | tool | Add a directory. |
23
24
  | `search_external_files` | tool | Glob-search added directories. |
24
25
 
@@ -26,16 +27,6 @@ Added directories return root `AGENTS.md`, `CLAUDE.md`, `.pi/AGENTS.md`, and `.p
26
27
 
27
28
  Search uses Node filesystem traversal, skips `.git` and `node_modules`, supports basename and relative-path globs, and caps results at 1,000 per call.
28
29
 
29
- ## Remove
30
+ ## State
30
31
 
31
- ```bash
32
- pi remove npm:@henryqw/pi-add-dir
33
- ```
34
-
35
- ## Development
36
-
37
- ```bash
38
- npm test --workspace @henryqw/pi-add-dir
39
- npm run typecheck --workspace @henryqw/pi-add-dir
40
- npm run pack:check --workspace @henryqw/pi-add-dir
41
- ```
32
+ Pi session entry `add-dir:state` stores added directories; package-managed, do not edit. Tree navigation restores the active branch's directories and reloads resources when that set changes.
@@ -1,5 +1,5 @@
1
1
  import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
2
- import { readdir } from "node:fs/promises";
2
+ import { glob } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import * as path from "node:path";
5
5
 
@@ -18,6 +18,11 @@ const CONTEXT_FILES = ["AGENTS.md", "CLAUDE.md"] as const;
18
18
  const SKILL_DIRS = [".pi/skills", ".agents/skills", ".claude/skills"] as const;
19
19
  const SKIPPED_SEARCH_DIRS = new Set([".git", "node_modules"]);
20
20
 
21
+ function isMissingPathError(error: unknown): boolean {
22
+ if (!error || typeof error !== "object" || !("code" in error)) return false;
23
+ return error.code === "ENOENT" || error.code === "ENOTDIR";
24
+ }
25
+
21
26
  export function expandUserPath(input: string): string {
22
27
  if (input === "~") return homedir();
23
28
  if (input.startsWith("~/") || input.startsWith(`~${path.sep}`)) return path.join(homedir(), input.slice(2));
@@ -29,16 +34,18 @@ export function resolveDir(input: string, cwd: string): string {
29
34
  const resolved = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
30
35
  try {
31
36
  return realpathSync(resolved);
32
- } catch {
33
- return path.resolve(resolved);
37
+ } catch (error) {
38
+ if (isMissingPathError(error)) return path.resolve(resolved);
39
+ throw error;
34
40
  }
35
41
  }
36
42
 
37
43
  export function dirExists(dir: string): boolean {
38
44
  try {
39
45
  return statSync(dir).isDirectory();
40
- } catch {
41
- return false;
46
+ } catch (error) {
47
+ if (isMissingPathError(error)) return false;
48
+ throw error;
42
49
  }
43
50
  }
44
51
 
@@ -63,7 +70,6 @@ function skillFiles(dir: string): Array<{ name: string; path: string }> {
63
70
 
64
71
  for (const skillDir of SKILL_DIRS) {
65
72
  const fullSkillDir = path.join(dir, skillDir);
66
- if (!dirExists(fullSkillDir)) continue;
67
73
  try {
68
74
  for (const entry of readdirSync(fullSkillDir, { withFileTypes: true })) {
69
75
  if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
@@ -97,7 +103,6 @@ export function collectSkillPaths(dirs: AddedDir[]): string[] {
97
103
  const paths: string[] = [];
98
104
  const names = new Set<string>();
99
105
  for (const dir of dirs) {
100
- if (!dirExists(dir.absolutePath)) continue;
101
106
  for (const skill of skillFiles(dir.absolutePath)) {
102
107
  if (names.has(skill.name)) continue;
103
108
  names.add(skill.name);
@@ -146,33 +151,21 @@ export async function findFiles(
146
151
 
147
152
  const matchPath = normalizedPattern.includes(path.sep);
148
153
  const results: string[] = [];
149
- const pending = [root];
150
154
 
151
155
  signal?.throwIfAborted();
152
- while (pending.length > 0) {
156
+ for await (const entry of glob("**/@(*|.*)", {
157
+ cwd: root,
158
+ withFileTypes: true,
159
+ exclude: (entry) => entry.isSymbolicLink() || (entry.isDirectory() && SKIPPED_SEARCH_DIRS.has(entry.name)),
160
+ })) {
153
161
  signal?.throwIfAborted();
154
- const current = pending.pop()!;
155
- let entries;
156
- try {
157
- entries = await readdir(current, { withFileTypes: true });
158
- } catch {
159
- continue;
160
- }
162
+ if (!entry.isFile()) continue;
161
163
 
162
- for (const entry of entries) {
163
- signal?.throwIfAborted();
164
- const fullPath = path.join(current, entry.name);
165
- if (entry.isDirectory()) {
166
- if (!SKIPPED_SEARCH_DIRS.has(entry.name)) pending.push(fullPath);
167
- continue;
168
- }
169
- if (!entry.isFile()) continue;
170
-
171
- const candidate = matchPath ? path.relative(root, fullPath) : entry.name;
172
- if (!path.matchesGlob(candidate, normalizedPattern)) continue;
173
- results.push(fullPath);
174
- if (results.length >= maxResults) return results;
175
- }
164
+ const fullPath = path.join(entry.parentPath, entry.name);
165
+ const candidate = matchPath ? path.relative(root, fullPath) : entry.name;
166
+ if (!path.matchesGlob(candidate, normalizedPattern)) continue;
167
+ results.push(fullPath);
168
+ if (results.length >= maxResults) return results;
176
169
  }
177
170
 
178
171
  return results;
@@ -117,13 +117,21 @@ export default function addDirExtension(pi: ExtensionAPI): void {
117
117
  }));
118
118
  }
119
119
 
120
- function reconstructState(ctx: ExtensionContext): void {
120
+ function reconstructState(ctx: ExtensionContext): boolean {
121
121
  currentCwd = ctx.cwd;
122
122
  const stateEntry = [...ctx.sessionManager.getBranch()]
123
123
  .reverse()
124
124
  .find((entry) => entry.type === "custom" && entry.customType === STATE_TYPE);
125
- addedDirs = stateEntry?.type === "custom" ? readState(stateEntry.data) : [];
125
+ const nextDirs = stateEntry?.type === "custom" ? readState(stateEntry.data) : [];
126
+ const changed =
127
+ addedDirs.length !== nextDirs.length ||
128
+ addedDirs.some(
129
+ (dir, index) =>
130
+ dir.absolutePath !== nextDirs[index]?.absolutePath || dir.label !== nextDirs[index]?.label,
131
+ );
132
+ addedDirs = nextDirs;
126
133
  updateWidget(ctx);
134
+ return changed;
127
135
  }
128
136
 
129
137
  function persistState(): void {
@@ -138,17 +146,24 @@ export default function addDirExtension(pi: ExtensionAPI): void {
138
146
  const input = dirPath.trim();
139
147
  if (!input) return { ok: false, message: "Directory path must not be blank.", hasNewSkills: false };
140
148
 
141
- const absolutePath = resolveDir(input, cwd);
142
- if (!dirExists(absolutePath)) {
143
- return { ok: false, message: `Directory does not exist: ${absolutePath}`, hasNewSkills: false };
149
+ let absolutePath: string;
150
+ try {
151
+ absolutePath = resolveDir(input, cwd);
152
+ if (!dirExists(absolutePath)) {
153
+ return { ok: false, message: `Directory does not exist: ${absolutePath}`, hasNewSkills: false };
154
+ }
155
+ } catch (error) {
156
+ const message = error instanceof Error ? error.message : String(error);
157
+ return { ok: false, message: `Cannot access directory: ${message}`, hasNewSkills: false };
144
158
  }
145
159
  if (addedDirs.some((dir) => dir.absolutePath === absolutePath)) {
146
160
  return { ok: false, message: `Already added: ${absolutePath}`, hasNewSkills: false };
147
161
  }
148
- if (isWithinDir(resolveDir(cwd, cwd), absolutePath)) {
162
+ const cwdPath = resolveDir(cwd, cwd);
163
+ if (isWithinDir(cwdPath, absolutePath) || isWithinDir(absolutePath, cwdPath)) {
149
164
  return {
150
165
  ok: false,
151
- message: "Directory is already in current working directory scope.",
166
+ message: "Directory overlaps current working directory scope.",
152
167
  hasNewSkills: false,
153
168
  };
154
169
  }
@@ -195,14 +210,26 @@ export default function addDirExtension(pi: ExtensionAPI): void {
195
210
  return skillPaths.length > 0 ? { skillPaths } : undefined;
196
211
  });
197
212
 
198
- pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
199
- pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
213
+ pi.on("session_start", async (_event, ctx) => {
214
+ reconstructState(ctx);
215
+ });
216
+ pi.on("session_tree", async (_event, ctx) => {
217
+ if (reconstructState(ctx)) pi.sendUserMessage("/dir-reload", { expandPromptTemplates: true });
218
+ });
200
219
 
201
220
  pi.on("before_agent_start", async (event) => {
202
221
  if (addedDirs.length === 0) return;
203
222
  return { systemPrompt: event.systemPrompt + buildContextInjection(addedDirs) };
204
223
  });
205
224
 
225
+ pi.registerCommand("dir-reload", {
226
+ description: "Reload external directory resources",
227
+ handler: async (_args, ctx) => {
228
+ await ctx.reload();
229
+ return;
230
+ },
231
+ });
232
+
206
233
  pi.registerCommand("dir-add", {
207
234
  description: "Add an external directory to this session",
208
235
  handler: async (args, ctx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-add-dir",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Add external directories to a Pi session with context, skills, and file search.",
5
5
  "keywords": [
6
6
  "pi-package",