@henryqw/pi-add-dir 1.0.3 → 2.1.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.
package/README.md CHANGED
@@ -16,8 +16,10 @@ Run `/dir-add`, enter a directory, then run `/dir-ls`. Pi lists the directory an
16
16
 
17
17
  | Surface | Type | Purpose |
18
18
  | --- | --- | --- |
19
- | `/dir-add` | command | Add directory; no path opens input. Supports `~`. |
20
- | `/dir-ls` | command | List directories; select one to remove. |
19
+ | `/dir-add [path]` | command | Add to the current session; no path opens input. Supports `~`. |
20
+ | `/dir-add --project [path]` | command | Persist for the current Git repository and all its linked worktrees. |
21
+ | `/dir-add --global [path]` | command | Persist for every Pi workspace for this user. |
22
+ | `/dir-ls` | command | List directories with their scope; select one to remove from that scope. |
21
23
  | `/dir-reload` | command | Reload external directory resources. |
22
24
  | `add_directory` | tool | Add a directory. |
23
25
  | `search_external_files` | tool | Glob-search added directories. |
@@ -28,8 +30,26 @@ Added directories give Pi these resources:
28
30
  - Skills that Pi loads from `.pi/skills`, `.agents/skills`, and `.claude/skills`.
29
31
  - Files in the editor's `@` autocomplete, with absolute paths.
30
32
 
31
- `/dir-add` reloads when it finds skills. `add_directory` reports when a reload is needed.
33
+ `/dir-add` reloads when it finds skills. `add_directory` reports when a reload is needed and always remains session-local.
34
+
35
+ ## Config
36
+
37
+ Package-owned: `~/.pi/agent/config/pi-add-dir/config.json`
38
+
39
+ | Name | Description | Values | Default |
40
+ | --- | --- | --- | --- |
41
+ | `directories` | Directories loaded for every workspace. `/dir-add --global` updates this list. | Array of unique absolute paths without control characters. | `[]` |
42
+
43
+ A missing file uses the default. Invalid config stops loading and is not overwritten. Reload Pi after editing the file manually.
44
+
45
+ ## State and storage
46
+
47
+ Session directories live in the current session branch. Project directories use the repository's repeatable local Git config key `pi-add-dir.directory`. Local Git config is shared by linked worktrees, cannot be injected by cloning a repository, and remains machine-local. `/dir-add --project` therefore requires a Git repository.
48
+
49
+ Persistent scopes are explicit because added directories can inject `AGENTS.md`, `CLAUDE.md`, and skills.
32
50
 
33
51
  ## Limits and recovery
34
52
 
35
53
  Search supports basename and relative-path globs. It skips `.git` and `node_modules`. It uses Node filesystem traversal and returns at most 1,000 results per call.
54
+
55
+ Missing directories and directories that overlap the current workspace remain configured but are skipped with a warning. Fix or remove them through `/dir-ls`.
@@ -0,0 +1,41 @@
1
+ import { isAbsolute } from "node:path";
2
+ import { createConfigStore } from "@henryqw/pi-config-store";
3
+
4
+ export interface AddDirConfig {
5
+ directories: string[];
6
+ }
7
+
8
+ function parseConfig(value: unknown): AddDirConfig {
9
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
10
+ throw new Error("Invalid pi-add-dir config: expected an object.");
11
+ }
12
+ const config = value as Record<string, unknown>;
13
+ if (Object.keys(config).length !== 1 || !Array.isArray(config.directories)) {
14
+ throw new Error('Invalid pi-add-dir config: expected only a "directories" array.');
15
+ }
16
+ const directories = config.directories;
17
+ if (
18
+ directories.some(
19
+ (directory) =>
20
+ typeof directory !== "string" ||
21
+ !isAbsolute(directory) ||
22
+ directory.length === 0 ||
23
+ /\p{C}/u.test(directory),
24
+ )
25
+ ) {
26
+ throw new Error("Invalid pi-add-dir config: directories must be absolute paths without control characters.");
27
+ }
28
+ if (new Set(directories).size !== directories.length) {
29
+ throw new Error("Invalid pi-add-dir config: directories must be unique.");
30
+ }
31
+ return { directories: [...directories] };
32
+ }
33
+
34
+ export function createAddDirConfigStore(agentDir?: string) {
35
+ return createConfigStore<AddDirConfig>({
36
+ extensionId: "pi-add-dir",
37
+ agentDir,
38
+ defaults: () => ({ directories: [] }),
39
+ parse: parseConfig,
40
+ });
41
+ }
@@ -1,4 +1,6 @@
1
- import { basename, isAbsolute, join, relative, sep } from "node:path";
1
+ import { lstatSync } from "node:fs";
2
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
3
+ import { setTimeout as delay } from "node:timers/promises";
2
4
  import {
3
5
  Text,
4
6
  truncateToWidth,
@@ -18,10 +20,32 @@ import {
18
20
  type AddedDir,
19
21
  type DirContext,
20
22
  } from "./add-dir-helpers.ts";
23
+ import { createAddDirConfigStore } from "./add-dir-config.ts";
21
24
 
22
25
  const STATE_TYPE = "add-dir:state";
26
+ const PROMPT_SECTION = "pi_add_dir";
23
27
  const DEFAULT_MAX_RESULTS = 50;
24
28
  const MAX_RESULTS = 1_000;
29
+ const PROJECT_CONFIG_KEY = "pi-add-dir.directory";
30
+ const PROJECT_CONFIG_RETRIES = 3;
31
+ const DIR_ADD_USAGE = "Usage: /dir-add [--project | --global] <path>";
32
+
33
+ type DirSource = "session" | "project" | "global";
34
+
35
+ interface ScopedDir extends AddedDir {
36
+ source: DirSource;
37
+ storedPath: string;
38
+ inactiveReason?: string;
39
+ }
40
+
41
+ interface ExtensionOptions {
42
+ agentDir?: string;
43
+ }
44
+
45
+ interface ParsedAddArgs {
46
+ source: DirSource;
47
+ path: string;
48
+ }
25
49
 
26
50
  const AddDirectoryParams = Type.Object({
27
51
  path: Type.String({ description: "Absolute or relative path to the directory to add", minLength: 1 }),
@@ -68,6 +92,45 @@ function readState(data: unknown): AddedDir[] {
68
92
  return Array.isArray(dirs) ? dirs.filter(isAddedDir) : [];
69
93
  }
70
94
 
95
+ function parseAddArgs(args: string | undefined): ParsedAddArgs {
96
+ const input = args?.trim() ?? "";
97
+ if (!input) return { source: "session", path: "" };
98
+ for (const source of ["project", "global"] as const) {
99
+ const flag = `--${source}`;
100
+ if (input === flag) return { source, path: "" };
101
+ if (input.startsWith(`${flag} `)) return { source, path: input.slice(flag.length).trim() };
102
+ }
103
+ if (input.startsWith("--")) throw new Error(DIR_ADD_USAGE);
104
+ return { source: "session", path: input };
105
+ }
106
+
107
+ function validateStoredPaths(paths: string[], source: DirSource): void {
108
+ if (
109
+ paths.some(
110
+ (path) => typeof path !== "string" || !isAbsolute(path) || path.length === 0 || /\p{C}/u.test(path),
111
+ )
112
+ ) {
113
+ throw new Error(`Invalid ${source} pi-add-dir configuration: expected absolute paths.`);
114
+ }
115
+ }
116
+
117
+ function hasGitMarker(cwd: string): boolean {
118
+ let directory = resolveDir(cwd, cwd);
119
+ while (true) {
120
+ try {
121
+ lstatSync(join(directory, ".git"));
122
+ return true;
123
+ } catch (error) {
124
+ if (!error || typeof error !== "object" || !("code" in error) || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) {
125
+ throw error;
126
+ }
127
+ }
128
+ const parent = dirname(directory);
129
+ if (parent === directory) return false;
130
+ directory = parent;
131
+ }
132
+ }
133
+
71
134
  function isWithinDir(dir: string, candidate: string): boolean {
72
135
  const relativePath = relative(dir, candidate);
73
136
  return relativePath === "" || (!relativePath.startsWith(`..${sep}`) && relativePath !== ".." && !isAbsolute(relativePath));
@@ -152,10 +215,28 @@ export function createExternalAutocompleteProvider(
152
215
  };
153
216
  }
154
217
 
155
- export default function addDirExtension(pi: ExtensionAPI): void {
156
- let addedDirs: AddedDir[] = [];
218
+ export default function addDirExtension(pi: ExtensionAPI, options: ExtensionOptions = {}): void {
219
+ const globalStore = createAddDirConfigStore(options.agentDir);
220
+ let sessionDirs: AddedDir[] = [];
221
+ let projectPaths: string[] = [];
222
+ let globalPaths: string[] = [];
223
+ let managedDirs: ScopedDir[] = [];
224
+ let addedDirs: ScopedDir[] = [];
157
225
  let currentCwd = "";
158
226
 
227
+ function globalConfigError(error: unknown): Error {
228
+ const message = error instanceof Error ? error.message : String(error);
229
+ return new Error(`Cannot load pi-add-dir global config at ${globalStore.path}: ${message}`, { cause: error });
230
+ }
231
+
232
+ function loadGlobalPaths(): string[] {
233
+ try {
234
+ return globalStore.loadSync().value.directories;
235
+ } catch (error) {
236
+ throw globalConfigError(error);
237
+ }
238
+ }
239
+
159
240
  function updateWidget(ctx: ExtensionContext): void {
160
241
  if (!ctx.hasUI) return;
161
242
  if (addedDirs.length === 0) {
@@ -184,91 +265,277 @@ export default function addDirExtension(pi: ExtensionAPI): void {
184
265
  }));
185
266
  }
186
267
 
187
- function reconstructState(ctx: ExtensionContext): boolean {
268
+ function warn(ctx: ExtensionContext, message: string): void {
269
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
270
+ else console.error(`pi-add-dir: ${message.slice(0, 1_000)}`);
271
+ }
272
+
273
+ function rebuildDirs(ctx: ExtensionContext, showWarnings = false): boolean {
274
+ const previous = addedDirs.map(({ absolutePath, source }) => `${source}\0${absolutePath}`);
275
+ const cwdPath = resolveDir(ctx.cwd, ctx.cwd);
276
+ const nextManaged: ScopedDir[] = [];
277
+ const nextActive: ScopedDir[] = [];
278
+
279
+ for (const dir of sessionDirs) {
280
+ const absolutePath = resolveDir(dir.absolutePath, ctx.cwd);
281
+ if (nextActive.some((existing) => existing.absolutePath === absolutePath)) continue;
282
+ const scoped = {
283
+ absolutePath,
284
+ label: basename(absolutePath) || absolutePath,
285
+ source: "session",
286
+ storedPath: dir.absolutePath,
287
+ } satisfies ScopedDir;
288
+ nextManaged.push(scoped);
289
+ nextActive.push(scoped);
290
+ }
291
+
292
+ for (const [source, paths] of [
293
+ ["project", projectPaths],
294
+ ["global", globalPaths],
295
+ ] as const) {
296
+ for (const storedPath of paths) {
297
+ let absolutePath = storedPath;
298
+ let inactiveReason: string | undefined;
299
+ try {
300
+ absolutePath = resolveDir(storedPath, ctx.cwd);
301
+ if (!dirExists(absolutePath)) inactiveReason = "directory does not exist";
302
+ } catch (error) {
303
+ inactiveReason = error instanceof Error ? error.message : String(error);
304
+ }
305
+ const duplicate = nextActive.find((dir) => dir.absolutePath === absolutePath);
306
+ if (!inactiveReason && duplicate) inactiveReason = `shadowed by ${duplicate.source} scope`;
307
+ if (
308
+ !inactiveReason &&
309
+ (isWithinDir(cwdPath, absolutePath) || isWithinDir(absolutePath, cwdPath))
310
+ ) {
311
+ inactiveReason = "overlaps current working directory scope";
312
+ }
313
+ const overlap = !inactiveReason
314
+ ? nextActive.find(
315
+ (dir) =>
316
+ isWithinDir(dir.absolutePath, absolutePath) || isWithinDir(absolutePath, dir.absolutePath),
317
+ )
318
+ : undefined;
319
+ if (overlap) inactiveReason = `overlaps ${overlap.source} directory ${overlap.absolutePath}`;
320
+ const scoped = {
321
+ absolutePath,
322
+ label: basename(absolutePath) || absolutePath,
323
+ source,
324
+ storedPath,
325
+ ...(inactiveReason ? { inactiveReason } : {}),
326
+ } satisfies ScopedDir;
327
+ nextManaged.push(scoped);
328
+ if (!inactiveReason) nextActive.push(scoped);
329
+ else if (showWarnings) warn(ctx, `Skipped ${source} external directory ${storedPath}: ${inactiveReason}.`);
330
+ }
331
+ }
332
+
333
+ managedDirs = nextManaged;
334
+ addedDirs = nextActive;
188
335
  currentCwd = ctx.cwd;
336
+ updateWidget(ctx);
337
+ const next = addedDirs.map(({ absolutePath, source }) => `${source}\0${absolutePath}`);
338
+ return previous.length !== next.length || previous.some((value, index) => value !== next[index]);
339
+ }
340
+
341
+ function reconstructState(ctx: ExtensionContext, showWarnings = false): boolean {
189
342
  const stateEntry = [...ctx.sessionManager.getBranch()]
190
343
  .reverse()
191
344
  .find((entry) => entry.type === "custom" && entry.customType === STATE_TYPE);
192
- const nextDirs = stateEntry?.type === "custom" ? readState(stateEntry.data) : [];
193
- const changed =
194
- addedDirs.length !== nextDirs.length ||
195
- addedDirs.some(
196
- (dir, index) =>
197
- dir.absolutePath !== nextDirs[index]?.absolutePath || dir.label !== nextDirs[index]?.label,
198
- );
199
- addedDirs = nextDirs;
200
- updateWidget(ctx);
201
- return changed;
345
+ sessionDirs = stateEntry?.type === "custom" ? readState(stateEntry.data) : [];
346
+ return rebuildDirs(ctx, showWarnings);
202
347
  }
203
348
 
204
349
  function persistState(): void {
205
- pi.appendEntry(STATE_TYPE, { dirs: addedDirs.map((dir) => ({ ...dir })) });
350
+ pi.appendEntry(STATE_TYPE, { dirs: sessionDirs.map((dir) => ({ ...dir })) });
206
351
  }
207
352
 
208
- function addDir(
353
+ async function readProjectPaths(cwd: string, required: boolean): Promise<string[]> {
354
+ const probe = await pi.exec("git", ["rev-parse", "--is-inside-work-tree"], { cwd });
355
+ if (probe.killed) throw new Error("Git repository check was interrupted.");
356
+ if (probe.code !== 0 || probe.stdout.trim() !== "true") {
357
+ if (hasGitMarker(cwd)) {
358
+ throw new Error(`Cannot inspect Git repository: ${probe.stderr.trim() || `git exited ${probe.code}`}`);
359
+ }
360
+ if (required) throw new Error("Project-persistent directories require a Git repository.");
361
+ return [];
362
+ }
363
+ const result = await pi.exec("git", ["config", "--local", "-z", "--get-all", PROJECT_CONFIG_KEY], { cwd });
364
+ if (result.killed) throw new Error("Reading project pi-add-dir configuration was interrupted.");
365
+ if (result.code === 1 && result.stdout.length === 0) return [];
366
+ if (result.code !== 0) {
367
+ throw new Error(`Cannot read project pi-add-dir configuration: ${result.stderr.trim() || `git exited ${result.code}`}`);
368
+ }
369
+ const paths = result.stdout.split("\0").filter((path) => path.length > 0);
370
+ validateStoredPaths(paths, "project");
371
+ return [...new Set(paths)];
372
+ }
373
+
374
+ async function mutateProjectConfig(args: string[], cwd: string, action: string): Promise<void> {
375
+ for (let attempt = 0; attempt < PROJECT_CONFIG_RETRIES; attempt += 1) {
376
+ const result = await pi.exec("git", ["config", "--local", ...args], { cwd });
377
+ if (!result.killed && result.code === 0) return;
378
+ const lockContention = /could not lock config file|unable to create .*\.lock/i.test(result.stderr);
379
+ if (result.killed || !lockContention || attempt === PROJECT_CONFIG_RETRIES - 1) {
380
+ throw new Error(`Cannot ${action}: ${result.stderr.trim() || "git config was interrupted"}`);
381
+ }
382
+ await delay(25 * (attempt + 1));
383
+ }
384
+ }
385
+
386
+ async function writePersistentPath(source: "project" | "global", absolutePath: string, ctx: ExtensionContext): Promise<void> {
387
+ if (source === "global") {
388
+ try {
389
+ const config = await globalStore.update((current) => ({
390
+ directories: current.directories.includes(absolutePath)
391
+ ? current.directories
392
+ : [...current.directories, absolutePath],
393
+ }));
394
+ globalPaths = config.directories;
395
+ return;
396
+ } catch (error) {
397
+ throw globalConfigError(error);
398
+ }
399
+ }
400
+ projectPaths = await readProjectPaths(ctx.cwd, true);
401
+ if (projectPaths.some((path) => resolveDir(path, ctx.cwd) === absolutePath)) return;
402
+ await mutateProjectConfig(
403
+ ["--replace-all", "--fixed-value", PROJECT_CONFIG_KEY, absolutePath, absolutePath],
404
+ ctx.cwd,
405
+ "save project directory",
406
+ );
407
+ projectPaths = await readProjectPaths(ctx.cwd, true);
408
+ }
409
+
410
+ async function addDir(
209
411
  dirPath: string,
412
+ source: DirSource,
210
413
  cwd: string,
211
414
  ctx: ExtensionContext,
212
- ): { ok: boolean; message: string; hasNewSkills: boolean; absolutePath?: string; context?: DirContext } {
415
+ ): Promise<{ ok: boolean; message: string; resourcesChanged: boolean; absolutePath?: string; context?: DirContext }> {
213
416
  const input = dirPath.trim();
214
- if (!input) return { ok: false, message: "Directory path must not be blank.", hasNewSkills: false };
417
+ if (!input) return { ok: false, message: "Directory path must not be blank.", resourcesChanged: false };
215
418
 
216
419
  let absolutePath: string;
217
420
  try {
218
421
  absolutePath = resolveDir(input, cwd);
219
422
  if (!dirExists(absolutePath)) {
220
- return { ok: false, message: `Directory does not exist: ${absolutePath}`, hasNewSkills: false };
423
+ return { ok: false, message: `Directory does not exist: ${absolutePath}`, resourcesChanged: false };
221
424
  }
222
425
  } catch (error) {
223
426
  const message = error instanceof Error ? error.message : String(error);
224
- return { ok: false, message: `Cannot access directory: ${message}`, hasNewSkills: false };
427
+ return { ok: false, message: `Cannot access directory: ${message}`, resourcesChanged: false };
225
428
  }
226
- if (addedDirs.some((dir) => dir.absolutePath === absolutePath)) {
227
- return { ok: false, message: `Already added: ${absolutePath}`, hasNewSkills: false };
429
+ if (source !== "session" && /\p{C}/u.test(absolutePath)) {
430
+ return { ok: false, message: "Persistent directory paths must not contain control characters.", resourcesChanged: false };
228
431
  }
229
- const cwdPath = resolveDir(cwd, cwd);
230
- if (isWithinDir(cwdPath, absolutePath) || isWithinDir(absolutePath, cwdPath)) {
432
+ const exact = managedDirs.filter((dir) => dir.absolutePath === absolutePath);
433
+ const sessionMatch = exact.find((dir) => dir.source === "session");
434
+ const persistentMatch = exact.find((dir) => dir.source !== "session");
435
+ if (persistentMatch || (source === "session" && sessionMatch)) {
231
436
  return {
232
437
  ok: false,
233
- message: "Directory overlaps current working directory scope.",
234
- hasNewSkills: false,
438
+ message: `Already added in ${persistentMatch?.source ?? "session"} scope: ${absolutePath}`,
439
+ resourcesChanged: false,
235
440
  };
236
441
  }
442
+ const cwdPath = resolveDir(cwd, cwd);
443
+ if (isWithinDir(cwdPath, absolutePath) || isWithinDir(absolutePath, cwdPath)) {
444
+ return { ok: false, message: "Directory overlaps current working directory scope.", resourcesChanged: false };
445
+ }
237
446
  const overlap = addedDirs.find(
238
- (dir) => isWithinDir(dir.absolutePath, absolutePath) || isWithinDir(absolutePath, dir.absolutePath),
447
+ (dir) =>
448
+ dir.absolutePath !== absolutePath &&
449
+ (isWithinDir(dir.absolutePath, absolutePath) || isWithinDir(absolutePath, dir.absolutePath)),
239
450
  );
240
451
  if (overlap) {
241
452
  return {
242
453
  ok: false,
243
454
  message: `Directory overlaps already-added directory: ${overlap.absolutePath}`,
244
- hasNewSkills: false,
455
+ resourcesChanged: false,
245
456
  };
246
457
  }
247
458
 
459
+ const previousSkillPaths = collectSkillPaths(addedDirs);
248
460
  const context = scanDirContext(absolutePath);
249
461
  const label = basename(absolutePath) || absolutePath;
250
- addedDirs.push({ absolutePath, label });
251
- persistState();
252
- updateWidget(ctx);
462
+ try {
463
+ if (source === "session") {
464
+ sessionDirs.push({ absolutePath, label });
465
+ persistState();
466
+ } else {
467
+ const previousSessionDirs = sessionDirs;
468
+ if (sessionMatch) {
469
+ sessionDirs = sessionDirs.filter((dir) => resolveDir(dir.absolutePath, ctx.cwd) !== absolutePath);
470
+ try {
471
+ persistState();
472
+ } catch (error) {
473
+ sessionDirs = previousSessionDirs;
474
+ throw error;
475
+ }
476
+ }
477
+ try {
478
+ await writePersistentPath(source, absolutePath, ctx);
479
+ } catch (error) {
480
+ if (sessionMatch) {
481
+ sessionDirs = previousSessionDirs;
482
+ try {
483
+ persistState();
484
+ } catch (recoveryError) {
485
+ const recoveryMessage = recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
486
+ throw new Error(`Persistent write failed and session recovery failed: ${recoveryMessage}`, { cause: error });
487
+ }
488
+ }
489
+ throw error;
490
+ }
491
+ }
492
+ } catch (error) {
493
+ const message = error instanceof Error ? error.message : String(error);
494
+ return { ok: false, message, resourcesChanged: false };
495
+ }
496
+ rebuildDirs(ctx);
497
+ const nextSkillPaths = new Set(collectSkillPaths(addedDirs));
498
+ const resourcesChanged =
499
+ previousSkillPaths.length !== nextSkillPaths.size || previousSkillPaths.some((path) => !nextSkillPaths.has(path));
253
500
 
254
- const hasNewSkills = context.skills.size > 0;
255
501
  return {
256
502
  ok: true,
257
- message: `Added ${label} (${absolutePath}).${contextSummary(context)}`,
258
- hasNewSkills,
503
+ message: `Added ${label} (${absolutePath}) to ${source} scope.${contextSummary(context)}`,
504
+ resourcesChanged,
259
505
  absolutePath,
260
506
  context,
261
507
  };
262
508
  }
263
509
 
264
- function removeDir(absolutePath: string, ctx: ExtensionContext): { ok: boolean; message: string } {
265
- const index = addedDirs.findIndex((dir) => dir.absolutePath === absolutePath);
266
- if (index < 0) return { ok: false, message: `Not found: ${absolutePath}` };
267
-
268
- const [removed] = addedDirs.splice(index, 1);
269
- persistState();
270
- updateWidget(ctx);
271
- return { ok: true, message: `Removed ${removed!.label} (${removed!.absolutePath}). Reloading resources...` };
510
+ async function removeDir(dir: ScopedDir, ctx: ExtensionContext): Promise<{ ok: boolean; message: string }> {
511
+ try {
512
+ if (dir.source === "session") {
513
+ sessionDirs = sessionDirs.filter(
514
+ (candidate) => resolveDir(candidate.absolutePath, ctx.cwd) !== dir.absolutePath,
515
+ );
516
+ persistState();
517
+ } else if (dir.source === "global") {
518
+ try {
519
+ const config = await globalStore.update((current) => ({
520
+ directories: current.directories.filter((path) => path !== dir.storedPath),
521
+ }));
522
+ globalPaths = config.directories;
523
+ } catch (error) {
524
+ throw globalConfigError(error);
525
+ }
526
+ } else {
527
+ await mutateProjectConfig(
528
+ ["--unset-all", "--fixed-value", PROJECT_CONFIG_KEY, dir.storedPath],
529
+ ctx.cwd,
530
+ "remove project directory",
531
+ );
532
+ projectPaths = projectPaths.filter((path) => path !== dir.storedPath);
533
+ }
534
+ } catch (error) {
535
+ return { ok: false, message: error instanceof Error ? error.message : String(error) };
536
+ }
537
+ rebuildDirs(ctx);
538
+ return { ok: true, message: `Removed ${dir.label} (${dir.storedPath}) from ${dir.source} scope. Reloading resources...` };
272
539
  }
273
540
 
274
541
  pi.on("resources_discover", (event) => {
@@ -278,16 +545,18 @@ export default function addDirExtension(pi: ExtensionAPI): void {
278
545
  });
279
546
 
280
547
  pi.on("session_start", async (_event, ctx) => {
281
- reconstructState(ctx);
548
+ globalPaths = loadGlobalPaths();
549
+ projectPaths = await readProjectPaths(ctx.cwd, false);
550
+ reconstructState(ctx, true);
282
551
  ctx.ui.addAutocompleteProvider((current) => createExternalAutocompleteProvider(current, () => addedDirs));
283
552
  });
284
553
  pi.on("session_tree", async (_event, ctx) => {
285
554
  if (reconstructState(ctx)) pi.sendUserMessage("/dir-reload", { expandPromptTemplates: true });
286
555
  });
287
556
 
288
- pi.on("before_agent_start", async (event) => {
557
+ pi.on("before_agent_start", (event) => {
289
558
  if (addedDirs.length === 0) return;
290
- return { systemPrompt: event.systemPrompt + buildContextInjection(addedDirs) };
559
+ event.systemPromptOptions.sections[PROMPT_SECTION] = buildContextInjection(addedDirs);
291
560
  });
292
561
 
293
562
  pi.registerCommand("dir-reload", {
@@ -299,37 +568,45 @@ export default function addDirExtension(pi: ExtensionAPI): void {
299
568
  });
300
569
 
301
570
  pi.registerCommand("dir-add", {
302
- description: "Add an external directory to this session",
571
+ description: "Add an external directory to this session, project, or global scope",
303
572
  handler: async (args, ctx) => {
304
- let inputPath = args?.trim();
305
- if (!inputPath) {
573
+ let parsed: ParsedAddArgs;
574
+ try {
575
+ parsed = parseAddArgs(args);
576
+ } catch (error) {
577
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
578
+ return;
579
+ }
580
+ if (!parsed.path) {
306
581
  const prompted = await ctx.ui.input("Directory path:", "");
307
582
  if (!prompted) return;
308
- inputPath = prompted;
583
+ parsed.path = prompted;
309
584
  }
310
585
 
311
- const result = addDir(inputPath, ctx.cwd, ctx);
312
- const message = result.ok && result.hasNewSkills ? `${result.message} Reloading to register skills...` : result.message;
586
+ const result = await addDir(parsed.path, parsed.source, ctx.cwd, ctx);
587
+ const message = result.ok && result.resourcesChanged ? `${result.message} Reloading external skills...` : result.message;
313
588
  ctx.ui.notify(message, result.ok ? "info" : "error");
314
- if (result.ok && result.hasNewSkills) await ctx.reload();
589
+ if (result.ok && result.resourcesChanged) await ctx.reload();
315
590
  },
316
591
  });
317
592
 
318
593
  pi.registerCommand("dir-ls", {
319
594
  description: "List external directories and select one to remove",
320
595
  handler: async (_args, ctx) => {
321
- if (addedDirs.length === 0) {
596
+ if (managedDirs.length === 0) {
322
597
  ctx.ui.notify("No external directories added. Use /dir-add <path> to add one.", "info");
323
598
  return;
324
599
  }
325
600
 
326
- const choices = addedDirs.map((dir) => `${dir.label} - ${dir.absolutePath}`);
601
+ const choices = managedDirs.map(
602
+ (dir) => `[${dir.source}] ${dir.label} - ${dir.storedPath}${dir.inactiveReason ? ` (inactive: ${dir.inactiveReason})` : ""}`,
603
+ );
327
604
  const selected = await ctx.ui.select("External directories — select one to remove:", choices);
328
605
  const selectedIndex = selected === undefined ? -1 : choices.indexOf(selected);
329
- const absolutePath = selectedIndex >= 0 ? addedDirs[selectedIndex]?.absolutePath : undefined;
330
- if (!absolutePath) return;
606
+ const dir = selectedIndex >= 0 ? managedDirs[selectedIndex] : undefined;
607
+ if (!dir) return;
331
608
 
332
- const result = removeDir(absolutePath, ctx);
609
+ const result = await removeDir(dir, ctx);
333
610
  ctx.ui.notify(result.message, result.ok ? "info" : "error");
334
611
  if (result.ok) await ctx.reload();
335
612
  },
@@ -351,7 +628,7 @@ export default function addDirExtension(pi: ExtensionAPI): void {
351
628
 
352
629
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
353
630
  const inputPath = params.path.trim();
354
- const result = addDir(inputPath, ctx.cwd, ctx);
631
+ const result = await addDir(inputPath, "session", ctx.cwd, ctx);
355
632
  if (!result.ok) throw new Error(result.message);
356
633
 
357
634
  const absolutePath = result.absolutePath!;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-add-dir",
3
- "version": "1.0.3",
3
+ "version": "2.1.0",
4
4
  "description": "Add external directories to a Pi session with context, skills, and file search.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -25,8 +25,8 @@
25
25
  "pack:check": "npm pack --dry-run"
26
26
  },
27
27
  "peerDependencies": {
28
- "@earendil-works/pi-coding-agent": "^0.85.1",
29
- "@earendil-works/pi-tui": "^0.85.1",
28
+ "@earendil-works/pi-coding-agent": "^0.86.0",
29
+ "@earendil-works/pi-tui": "^0.86.0",
30
30
  "typebox": "^1.3.15"
31
31
  },
32
32
  "repository": {
@@ -45,5 +45,12 @@
45
45
  "./extensions/add-dir.ts"
46
46
  ],
47
47
  "image": "https://raw.githubusercontent.com/HenryQW/pi-harness/main/extensions/pi-add-dir/example.png"
48
+ },
49
+ "dependencies": {
50
+ "@henryqw/pi-config-store": "^2.0.0"
51
+ },
52
+ "devDependencies": {
53
+ "@earendil-works/pi-coding-agent": "0.86.0",
54
+ "@earendil-works/pi-tui": "0.86.0"
48
55
  }
49
56
  }