@henryqw/pi-add-dir 2.0.0 → 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,11 +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";
23
26
  const PROMPT_SECTION = "pi_add_dir";
24
27
  const DEFAULT_MAX_RESULTS = 50;
25
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
+ }
26
49
 
27
50
  const AddDirectoryParams = Type.Object({
28
51
  path: Type.String({ description: "Absolute or relative path to the directory to add", minLength: 1 }),
@@ -69,6 +92,45 @@ function readState(data: unknown): AddedDir[] {
69
92
  return Array.isArray(dirs) ? dirs.filter(isAddedDir) : [];
70
93
  }
71
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
+
72
134
  function isWithinDir(dir: string, candidate: string): boolean {
73
135
  const relativePath = relative(dir, candidate);
74
136
  return relativePath === "" || (!relativePath.startsWith(`..${sep}`) && relativePath !== ".." && !isAbsolute(relativePath));
@@ -153,10 +215,28 @@ export function createExternalAutocompleteProvider(
153
215
  };
154
216
  }
155
217
 
156
- export default function addDirExtension(pi: ExtensionAPI): void {
157
- 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[] = [];
158
225
  let currentCwd = "";
159
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
+
160
240
  function updateWidget(ctx: ExtensionContext): void {
161
241
  if (!ctx.hasUI) return;
162
242
  if (addedDirs.length === 0) {
@@ -185,91 +265,277 @@ export default function addDirExtension(pi: ExtensionAPI): void {
185
265
  }));
186
266
  }
187
267
 
188
- 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;
189
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 {
190
342
  const stateEntry = [...ctx.sessionManager.getBranch()]
191
343
  .reverse()
192
344
  .find((entry) => entry.type === "custom" && entry.customType === STATE_TYPE);
193
- const nextDirs = stateEntry?.type === "custom" ? readState(stateEntry.data) : [];
194
- const changed =
195
- addedDirs.length !== nextDirs.length ||
196
- addedDirs.some(
197
- (dir, index) =>
198
- dir.absolutePath !== nextDirs[index]?.absolutePath || dir.label !== nextDirs[index]?.label,
199
- );
200
- addedDirs = nextDirs;
201
- updateWidget(ctx);
202
- return changed;
345
+ sessionDirs = stateEntry?.type === "custom" ? readState(stateEntry.data) : [];
346
+ return rebuildDirs(ctx, showWarnings);
203
347
  }
204
348
 
205
349
  function persistState(): void {
206
- pi.appendEntry(STATE_TYPE, { dirs: addedDirs.map((dir) => ({ ...dir })) });
350
+ pi.appendEntry(STATE_TYPE, { dirs: sessionDirs.map((dir) => ({ ...dir })) });
207
351
  }
208
352
 
209
- 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(
210
411
  dirPath: string,
412
+ source: DirSource,
211
413
  cwd: string,
212
414
  ctx: ExtensionContext,
213
- ): { ok: boolean; message: string; hasNewSkills: boolean; absolutePath?: string; context?: DirContext } {
415
+ ): Promise<{ ok: boolean; message: string; resourcesChanged: boolean; absolutePath?: string; context?: DirContext }> {
214
416
  const input = dirPath.trim();
215
- 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 };
216
418
 
217
419
  let absolutePath: string;
218
420
  try {
219
421
  absolutePath = resolveDir(input, cwd);
220
422
  if (!dirExists(absolutePath)) {
221
- return { ok: false, message: `Directory does not exist: ${absolutePath}`, hasNewSkills: false };
423
+ return { ok: false, message: `Directory does not exist: ${absolutePath}`, resourcesChanged: false };
222
424
  }
223
425
  } catch (error) {
224
426
  const message = error instanceof Error ? error.message : String(error);
225
- return { ok: false, message: `Cannot access directory: ${message}`, hasNewSkills: false };
427
+ return { ok: false, message: `Cannot access directory: ${message}`, resourcesChanged: false };
226
428
  }
227
- if (addedDirs.some((dir) => dir.absolutePath === absolutePath)) {
228
- 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 };
229
431
  }
230
- const cwdPath = resolveDir(cwd, cwd);
231
- 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)) {
232
436
  return {
233
437
  ok: false,
234
- message: "Directory overlaps current working directory scope.",
235
- hasNewSkills: false,
438
+ message: `Already added in ${persistentMatch?.source ?? "session"} scope: ${absolutePath}`,
439
+ resourcesChanged: false,
236
440
  };
237
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
+ }
238
446
  const overlap = addedDirs.find(
239
- (dir) => isWithinDir(dir.absolutePath, absolutePath) || isWithinDir(absolutePath, dir.absolutePath),
447
+ (dir) =>
448
+ dir.absolutePath !== absolutePath &&
449
+ (isWithinDir(dir.absolutePath, absolutePath) || isWithinDir(absolutePath, dir.absolutePath)),
240
450
  );
241
451
  if (overlap) {
242
452
  return {
243
453
  ok: false,
244
454
  message: `Directory overlaps already-added directory: ${overlap.absolutePath}`,
245
- hasNewSkills: false,
455
+ resourcesChanged: false,
246
456
  };
247
457
  }
248
458
 
459
+ const previousSkillPaths = collectSkillPaths(addedDirs);
249
460
  const context = scanDirContext(absolutePath);
250
461
  const label = basename(absolutePath) || absolutePath;
251
- addedDirs.push({ absolutePath, label });
252
- persistState();
253
- 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));
254
500
 
255
- const hasNewSkills = context.skills.size > 0;
256
501
  return {
257
502
  ok: true,
258
- message: `Added ${label} (${absolutePath}).${contextSummary(context)}`,
259
- hasNewSkills,
503
+ message: `Added ${label} (${absolutePath}) to ${source} scope.${contextSummary(context)}`,
504
+ resourcesChanged,
260
505
  absolutePath,
261
506
  context,
262
507
  };
263
508
  }
264
509
 
265
- function removeDir(absolutePath: string, ctx: ExtensionContext): { ok: boolean; message: string } {
266
- const index = addedDirs.findIndex((dir) => dir.absolutePath === absolutePath);
267
- if (index < 0) return { ok: false, message: `Not found: ${absolutePath}` };
268
-
269
- const [removed] = addedDirs.splice(index, 1);
270
- persistState();
271
- updateWidget(ctx);
272
- 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...` };
273
539
  }
274
540
 
275
541
  pi.on("resources_discover", (event) => {
@@ -279,7 +545,9 @@ export default function addDirExtension(pi: ExtensionAPI): void {
279
545
  });
280
546
 
281
547
  pi.on("session_start", async (_event, ctx) => {
282
- reconstructState(ctx);
548
+ globalPaths = loadGlobalPaths();
549
+ projectPaths = await readProjectPaths(ctx.cwd, false);
550
+ reconstructState(ctx, true);
283
551
  ctx.ui.addAutocompleteProvider((current) => createExternalAutocompleteProvider(current, () => addedDirs));
284
552
  });
285
553
  pi.on("session_tree", async (_event, ctx) => {
@@ -300,37 +568,45 @@ export default function addDirExtension(pi: ExtensionAPI): void {
300
568
  });
301
569
 
302
570
  pi.registerCommand("dir-add", {
303
- description: "Add an external directory to this session",
571
+ description: "Add an external directory to this session, project, or global scope",
304
572
  handler: async (args, ctx) => {
305
- let inputPath = args?.trim();
306
- 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) {
307
581
  const prompted = await ctx.ui.input("Directory path:", "");
308
582
  if (!prompted) return;
309
- inputPath = prompted;
583
+ parsed.path = prompted;
310
584
  }
311
585
 
312
- const result = addDir(inputPath, ctx.cwd, ctx);
313
- 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;
314
588
  ctx.ui.notify(message, result.ok ? "info" : "error");
315
- if (result.ok && result.hasNewSkills) await ctx.reload();
589
+ if (result.ok && result.resourcesChanged) await ctx.reload();
316
590
  },
317
591
  });
318
592
 
319
593
  pi.registerCommand("dir-ls", {
320
594
  description: "List external directories and select one to remove",
321
595
  handler: async (_args, ctx) => {
322
- if (addedDirs.length === 0) {
596
+ if (managedDirs.length === 0) {
323
597
  ctx.ui.notify("No external directories added. Use /dir-add <path> to add one.", "info");
324
598
  return;
325
599
  }
326
600
 
327
- 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
+ );
328
604
  const selected = await ctx.ui.select("External directories — select one to remove:", choices);
329
605
  const selectedIndex = selected === undefined ? -1 : choices.indexOf(selected);
330
- const absolutePath = selectedIndex >= 0 ? addedDirs[selectedIndex]?.absolutePath : undefined;
331
- if (!absolutePath) return;
606
+ const dir = selectedIndex >= 0 ? managedDirs[selectedIndex] : undefined;
607
+ if (!dir) return;
332
608
 
333
- const result = removeDir(absolutePath, ctx);
609
+ const result = await removeDir(dir, ctx);
334
610
  ctx.ui.notify(result.message, result.ok ? "info" : "error");
335
611
  if (result.ok) await ctx.reload();
336
612
  },
@@ -352,7 +628,7 @@ export default function addDirExtension(pi: ExtensionAPI): void {
352
628
 
353
629
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
354
630
  const inputPath = params.path.trim();
355
- const result = addDir(inputPath, ctx.cwd, ctx);
631
+ const result = await addDir(inputPath, "session", ctx.cwd, ctx);
356
632
  if (!result.ok) throw new Error(result.message);
357
633
 
358
634
  const absolutePath = result.absolutePath!;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-add-dir",
3
- "version": "2.0.0",
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",
@@ -46,6 +46,9 @@
46
46
  ],
47
47
  "image": "https://raw.githubusercontent.com/HenryQW/pi-harness/main/extensions/pi-add-dir/example.png"
48
48
  },
49
+ "dependencies": {
50
+ "@henryqw/pi-config-store": "^2.0.0"
51
+ },
49
52
  "devDependencies": {
50
53
  "@earendil-works/pi-coding-agent": "0.86.0",
51
54
  "@earendil-works/pi-tui": "0.86.0"