@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 +23 -3
- package/extensions/add-dir-config.ts +41 -0
- package/extensions/add-dir.ts +332 -56
- package/package.json +4 -1
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
|
|
20
|
-
| `/dir-
|
|
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
|
+
}
|
package/extensions/add-dir.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
194
|
-
|
|
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:
|
|
350
|
+
pi.appendEntry(STATE_TYPE, { dirs: sessionDirs.map((dir) => ({ ...dir })) });
|
|
207
351
|
}
|
|
208
352
|
|
|
209
|
-
function
|
|
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;
|
|
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.",
|
|
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}`,
|
|
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}`,
|
|
427
|
+
return { ok: false, message: `Cannot access directory: ${message}`, resourcesChanged: false };
|
|
226
428
|
}
|
|
227
|
-
if (
|
|
228
|
-
return { ok: false, message:
|
|
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
|
|
231
|
-
|
|
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:
|
|
235
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
|
306
|
-
|
|
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
|
-
|
|
583
|
+
parsed.path = prompted;
|
|
310
584
|
}
|
|
311
585
|
|
|
312
|
-
const result = addDir(
|
|
313
|
-
const message = result.ok && result.
|
|
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.
|
|
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 (
|
|
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 =
|
|
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
|
|
331
|
-
if (!
|
|
606
|
+
const dir = selectedIndex >= 0 ? managedDirs[selectedIndex] : undefined;
|
|
607
|
+
if (!dir) return;
|
|
332
608
|
|
|
333
|
-
const result = removeDir(
|
|
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.
|
|
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"
|