@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 +23 -3
- package/extensions/add-dir-config.ts +41 -0
- package/extensions/add-dir.ts +335 -58
- package/package.json +10 -3
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,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
|
-
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
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:
|
|
350
|
+
pi.appendEntry(STATE_TYPE, { dirs: sessionDirs.map((dir) => ({ ...dir })) });
|
|
206
351
|
}
|
|
207
352
|
|
|
208
|
-
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(
|
|
209
411
|
dirPath: string,
|
|
412
|
+
source: DirSource,
|
|
210
413
|
cwd: string,
|
|
211
414
|
ctx: ExtensionContext,
|
|
212
|
-
): { ok: boolean; message: string;
|
|
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.",
|
|
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}`,
|
|
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}`,
|
|
427
|
+
return { ok: false, message: `Cannot access directory: ${message}`, resourcesChanged: false };
|
|
225
428
|
}
|
|
226
|
-
if (
|
|
227
|
-
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 };
|
|
228
431
|
}
|
|
229
|
-
const
|
|
230
|
-
|
|
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:
|
|
234
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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(
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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",
|
|
557
|
+
pi.on("before_agent_start", (event) => {
|
|
289
558
|
if (addedDirs.length === 0) return;
|
|
290
|
-
|
|
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
|
|
305
|
-
|
|
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
|
-
|
|
583
|
+
parsed.path = prompted;
|
|
309
584
|
}
|
|
310
585
|
|
|
311
|
-
const result = addDir(
|
|
312
|
-
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;
|
|
313
588
|
ctx.ui.notify(message, result.ok ? "info" : "error");
|
|
314
|
-
if (result.ok && result.
|
|
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 (
|
|
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 =
|
|
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
|
|
330
|
-
if (!
|
|
606
|
+
const dir = selectedIndex >= 0 ? managedDirs[selectedIndex] : undefined;
|
|
607
|
+
if (!dir) return;
|
|
331
608
|
|
|
332
|
-
const result = removeDir(
|
|
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
|
+
"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.
|
|
29
|
-
"@earendil-works/pi-tui": "^0.
|
|
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
|
}
|