@henryqw/pi-add-dir 0.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/LICENSE +23 -0
- package/README.md +36 -0
- package/extensions/add-dir-helpers.ts +214 -0
- package/extensions/add-dir.ts +406 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 itisbryan
|
|
4
|
+
Modifications Copyright (c) 2026 shelken
|
|
5
|
+
Modifications Copyright (c) 2026 Henry Wang
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# `@henryqw/pi-add-dir`
|
|
2
|
+
|
|
3
|
+
Pi extension for adding external directories to current session, based on [shelken's pi-add-dir](https://github.com/shelken/pi-extensions/tree/main/extensions/pi-add-dir). It injects `AGENTS.md` / `CLAUDE.md`, discovers skills, persists directory state in session history, and searches external files.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@henryqw/pi-add-dir
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Use
|
|
12
|
+
|
|
13
|
+
| Surface | Purpose |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `/dir-add [path]` | Add directory; no path opens input. Supports `~`. |
|
|
16
|
+
| `/dir-ls` | List directories; select one to remove. |
|
|
17
|
+
| `add_directory` | Agent tool for adding directory. |
|
|
18
|
+
| `search_external_files` | Agent tool for glob-searching external files. |
|
|
19
|
+
|
|
20
|
+
Added directories return root `AGENTS.md`, `CLAUDE.md`, `.pi/AGENTS.md`, and `.pi/CLAUDE.md` in `add_directory` results and inject them into future prompts. Skills load from `.pi/skills`, `.agents/skills`, and `.claude/skills`, then register as `/skill:<name>` after reload. `/dir-add` reloads automatically when it finds skills; `add_directory` reports when `/reload` is needed.
|
|
21
|
+
|
|
22
|
+
Search uses native Node filesystem traversal, skips `.git` and `node_modules`, supports basename and relative-path globs, and caps results at 1,000 per call. No config file or runtime dependency.
|
|
23
|
+
|
|
24
|
+
## Remove
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pi remove npm:@henryqw/pi-add-dir
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Development
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm test
|
|
34
|
+
npm run typecheck
|
|
35
|
+
npm run pack:check
|
|
36
|
+
```
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
|
|
6
|
+
export interface AddedDir {
|
|
7
|
+
absolutePath: string;
|
|
8
|
+
label: string;
|
|
9
|
+
addedAt: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DirContext {
|
|
13
|
+
dir: string;
|
|
14
|
+
agentsMd: string | null;
|
|
15
|
+
claudeMd: string | null;
|
|
16
|
+
skillPaths: Map<string, string>;
|
|
17
|
+
skills: Map<string, string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const CONTEXT_FILES = ["AGENTS.md", "CLAUDE.md"] as const;
|
|
21
|
+
const SKILL_DIRS = [".pi/skills", ".agents/skills", ".claude/skills"] as const;
|
|
22
|
+
const SKIPPED_SEARCH_DIRS = new Set([".git", "node_modules"]);
|
|
23
|
+
|
|
24
|
+
export function expandUserPath(input: string): string {
|
|
25
|
+
if (input === "~") return homedir();
|
|
26
|
+
if (input.startsWith("~/") || input.startsWith(`~${path.sep}`)) return path.join(homedir(), input.slice(2));
|
|
27
|
+
return input;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveDir(input: string, cwd: string): string {
|
|
31
|
+
const expanded = expandUserPath(input);
|
|
32
|
+
const resolved = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
|
|
33
|
+
try {
|
|
34
|
+
return realpathSync(resolved);
|
|
35
|
+
} catch {
|
|
36
|
+
return path.resolve(resolved);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function dirExists(dir: string): boolean {
|
|
41
|
+
try {
|
|
42
|
+
return statSync(dir).isDirectory();
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function readFileSafe(filePath: string): string | null {
|
|
49
|
+
try {
|
|
50
|
+
return readFileSync(filePath, "utf8");
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readContextFile(dir: string, name: (typeof CONTEXT_FILES)[number]): string | null {
|
|
57
|
+
const contents = [readFileSafe(path.join(dir, name)), readFileSafe(path.join(dir, ".pi", name))].filter(
|
|
58
|
+
(content): content is string => content !== null,
|
|
59
|
+
);
|
|
60
|
+
return contents.length > 0 ? contents.join("\n\n") : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function skillFiles(dir: string): Array<{ name: string; path: string }> {
|
|
64
|
+
const files: Array<{ name: string; path: string }> = [];
|
|
65
|
+
const names = new Set<string>();
|
|
66
|
+
|
|
67
|
+
for (const skillDir of SKILL_DIRS) {
|
|
68
|
+
const fullSkillDir = path.join(dir, skillDir);
|
|
69
|
+
if (!dirExists(fullSkillDir)) continue;
|
|
70
|
+
try {
|
|
71
|
+
for (const entry of readdirSync(fullSkillDir, { withFileTypes: true })) {
|
|
72
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
73
|
+
const skillPath = path.join(fullSkillDir, entry.name, "SKILL.md");
|
|
74
|
+
try {
|
|
75
|
+
if (statSync(skillPath).isFile() && !names.has(entry.name)) {
|
|
76
|
+
names.add(entry.name);
|
|
77
|
+
files.push({ name: entry.name, path: skillPath });
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Skill may disappear while resources are being discovered.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Skip unreadable skill directories.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return files;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function scanDirContext(dir: string): DirContext {
|
|
92
|
+
const ctx: DirContext = {
|
|
93
|
+
dir,
|
|
94
|
+
agentsMd: readContextFile(dir, "AGENTS.md"),
|
|
95
|
+
claudeMd: readContextFile(dir, "CLAUDE.md"),
|
|
96
|
+
skillPaths: new Map(),
|
|
97
|
+
skills: new Map(),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
for (const skill of skillFiles(dir)) {
|
|
101
|
+
const content = readFileSafe(skill.path);
|
|
102
|
+
if (content === null) continue;
|
|
103
|
+
ctx.skillPaths.set(skill.name, skill.path);
|
|
104
|
+
ctx.skills.set(skill.name, content);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return ctx;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function collectSkillPaths(dirs: AddedDir[]): string[] {
|
|
111
|
+
const paths: string[] = [];
|
|
112
|
+
const names = new Set<string>();
|
|
113
|
+
for (const dir of dirs) {
|
|
114
|
+
if (!dirExists(dir.absolutePath)) continue;
|
|
115
|
+
for (const skill of skillFiles(dir.absolutePath)) {
|
|
116
|
+
if (names.has(skill.name)) continue;
|
|
117
|
+
names.add(skill.name);
|
|
118
|
+
paths.push(skill.path);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return paths;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function skillDescription(content: string): string {
|
|
125
|
+
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
|
|
126
|
+
const value = frontmatter?.match(/^description:\s*(.*?)\s*$/m)?.[1];
|
|
127
|
+
if (!value || value === ">" || value === "|") return "No description";
|
|
128
|
+
return value.replace(/^("|')|("|')$/g, "").trim() || "No description";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function buildContextInjection(dirs: AddedDir[]): string {
|
|
132
|
+
if (dirs.length === 0) return "";
|
|
133
|
+
|
|
134
|
+
const sections = [
|
|
135
|
+
"\n\n## External Directories (added via pi-add-dir)",
|
|
136
|
+
`\nThe following ${dirs.length} external director${dirs.length === 1 ? "y is" : "ies are"} included in this session. You can read, edit, and write files in these directories using absolute paths.\n`,
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
const registeredSkills = new Set<string>();
|
|
140
|
+
for (const dir of dirs) {
|
|
141
|
+
const ctx = scanDirContext(dir.absolutePath);
|
|
142
|
+
const skills = [...ctx.skills].filter(([name]) => {
|
|
143
|
+
if (registeredSkills.has(name)) return false;
|
|
144
|
+
registeredSkills.add(name);
|
|
145
|
+
return true;
|
|
146
|
+
});
|
|
147
|
+
sections.push(`### ${dir.label} - \`${dir.absolutePath}\``);
|
|
148
|
+
|
|
149
|
+
if (ctx.agentsMd) sections.push(`\n#### AGENTS.md (from ${dir.label})\n${ctx.agentsMd}`);
|
|
150
|
+
if (ctx.claudeMd) sections.push(`\n#### CLAUDE.md (from ${dir.label})\n${ctx.claudeMd}`);
|
|
151
|
+
|
|
152
|
+
if (skills.length > 0) {
|
|
153
|
+
sections.push(`\n#### Skills from ${dir.label} (registered as /skill:name commands):`);
|
|
154
|
+
for (const [name, content] of skills) {
|
|
155
|
+
sections.push(
|
|
156
|
+
`- **${name}**: ${skillDescription(content)} - use \`/skill:${name}\` or read \`${ctx.skillPaths.get(name)}\``,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return sections.join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function normalizePattern(pattern: string): string {
|
|
166
|
+
let normalized = pattern.trim();
|
|
167
|
+
if (path.sep === "\\") normalized = normalized.replaceAll("/", "\\");
|
|
168
|
+
const prefix = `.${path.sep}`;
|
|
169
|
+
if (normalized.startsWith(prefix)) normalized = normalized.slice(prefix.length);
|
|
170
|
+
return normalized;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function findFiles(
|
|
174
|
+
root: string,
|
|
175
|
+
pattern: string,
|
|
176
|
+
maxResults: number,
|
|
177
|
+
signal?: AbortSignal,
|
|
178
|
+
): Promise<string[]> {
|
|
179
|
+
const normalizedPattern = normalizePattern(pattern);
|
|
180
|
+
if (!normalizedPattern) throw new Error("File pattern must not be blank.");
|
|
181
|
+
|
|
182
|
+
const matchPath = normalizedPattern.includes(path.sep);
|
|
183
|
+
const results: string[] = [];
|
|
184
|
+
const pending = [root];
|
|
185
|
+
|
|
186
|
+
signal?.throwIfAborted();
|
|
187
|
+
while (pending.length > 0) {
|
|
188
|
+
signal?.throwIfAborted();
|
|
189
|
+
const current = pending.pop()!;
|
|
190
|
+
let entries;
|
|
191
|
+
try {
|
|
192
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
193
|
+
} catch {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
signal?.throwIfAborted();
|
|
199
|
+
const fullPath = path.join(current, entry.name);
|
|
200
|
+
if (entry.isDirectory()) {
|
|
201
|
+
if (!SKIPPED_SEARCH_DIRS.has(entry.name)) pending.push(fullPath);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (!entry.isFile()) continue;
|
|
205
|
+
|
|
206
|
+
const candidate = matchPath ? path.relative(root, fullPath) : entry.name;
|
|
207
|
+
if (!path.matchesGlob(candidate, normalizedPattern)) continue;
|
|
208
|
+
results.push(fullPath);
|
|
209
|
+
if (results.length >= maxResults) return results;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return results;
|
|
214
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { basename, isAbsolute, relative, sep } from "node:path";
|
|
2
|
+
import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import {
|
|
6
|
+
buildContextInjection,
|
|
7
|
+
collectSkillPaths,
|
|
8
|
+
dirExists,
|
|
9
|
+
findFiles,
|
|
10
|
+
resolveDir,
|
|
11
|
+
scanDirContext,
|
|
12
|
+
type AddedDir,
|
|
13
|
+
type DirContext,
|
|
14
|
+
} from "./add-dir-helpers.ts";
|
|
15
|
+
|
|
16
|
+
const STATE_TYPE = "add-dir:state";
|
|
17
|
+
const DEFAULT_MAX_RESULTS = 50;
|
|
18
|
+
const MAX_RESULTS = 1_000;
|
|
19
|
+
|
|
20
|
+
const AddDirectoryParams = Type.Object({
|
|
21
|
+
path: Type.String({ description: "Absolute or relative path to the directory to add", minLength: 1 }),
|
|
22
|
+
reason: Type.Optional(Type.String({ description: "Why this directory is being added (shown to user)" })),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const SearchExternalFilesParams = Type.Object({
|
|
26
|
+
pattern: Type.String({
|
|
27
|
+
description: "File name or glob pattern to search for (for example, '*.ts' or 'src/**/*.test.ts')",
|
|
28
|
+
minLength: 1,
|
|
29
|
+
}),
|
|
30
|
+
maxResults: Type.Optional(
|
|
31
|
+
Type.Integer({
|
|
32
|
+
description: `Maximum number of results (default: ${DEFAULT_MAX_RESULTS}, maximum: ${MAX_RESULTS})`,
|
|
33
|
+
minimum: 1,
|
|
34
|
+
maximum: MAX_RESULTS,
|
|
35
|
+
}),
|
|
36
|
+
),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
interface AddDirectoryDetails {
|
|
40
|
+
directory: string;
|
|
41
|
+
hasAgentsMd: boolean;
|
|
42
|
+
hasClaudeMd: boolean;
|
|
43
|
+
skillCount: number;
|
|
44
|
+
skillNames: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface SearchDetails {
|
|
48
|
+
totalFound: number;
|
|
49
|
+
pattern: string;
|
|
50
|
+
dirCount: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isAddedDir(value: unknown): value is AddedDir {
|
|
54
|
+
if (!value || typeof value !== "object") return false;
|
|
55
|
+
const dir = value as Partial<AddedDir>;
|
|
56
|
+
return (
|
|
57
|
+
typeof dir.absolutePath === "string" &&
|
|
58
|
+
isAbsolute(dir.absolutePath) &&
|
|
59
|
+
typeof dir.label === "string" &&
|
|
60
|
+
typeof dir.addedAt === "number" &&
|
|
61
|
+
Number.isFinite(dir.addedAt)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readState(data: unknown): AddedDir[] {
|
|
66
|
+
if (!data || typeof data !== "object") return [];
|
|
67
|
+
const dirs = (data as { dirs?: unknown }).dirs;
|
|
68
|
+
return Array.isArray(dirs) ? dirs.filter(isAddedDir) : [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function cleanPath(input: string): string {
|
|
72
|
+
return input.trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isWithinDir(dir: string, candidate: string): boolean {
|
|
76
|
+
const relativePath = relative(dir, candidate);
|
|
77
|
+
return relativePath === "" || (!relativePath.startsWith(`..${sep}`) && relativePath !== ".." && !isAbsolute(relativePath));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function maxResults(value: number | undefined): number {
|
|
81
|
+
const limit = value ?? DEFAULT_MAX_RESULTS;
|
|
82
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_RESULTS) {
|
|
83
|
+
throw new Error(`maxResults must be an integer from 1 to ${MAX_RESULTS}.`);
|
|
84
|
+
}
|
|
85
|
+
return limit;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function contextDetails(dirCtx: DirContext, absolutePath: string): AddDirectoryDetails {
|
|
89
|
+
return {
|
|
90
|
+
directory: absolutePath,
|
|
91
|
+
hasAgentsMd: dirCtx.agentsMd !== null,
|
|
92
|
+
hasClaudeMd: dirCtx.claudeMd !== null,
|
|
93
|
+
skillCount: dirCtx.skills.size,
|
|
94
|
+
skillNames: [...dirCtx.skills.keys()],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function contextSummary(dirCtx: DirContext): string {
|
|
99
|
+
const found: string[] = [];
|
|
100
|
+
if (dirCtx.agentsMd !== null) found.push("AGENTS.md");
|
|
101
|
+
if (dirCtx.claudeMd !== null) found.push("CLAUDE.md");
|
|
102
|
+
if (dirCtx.skills.size > 0) found.push(`${dirCtx.skills.size} skill(s)`);
|
|
103
|
+
return found.length > 0 ? ` Found: ${found.join(", ")}.` : " No context files found.";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default function addDirExtension(pi: ExtensionAPI): void {
|
|
107
|
+
let addedDirs: AddedDir[] = [];
|
|
108
|
+
let currentCwd = "";
|
|
109
|
+
|
|
110
|
+
function updateWidget(ctx: ExtensionContext): void {
|
|
111
|
+
if (!ctx.hasUI) return;
|
|
112
|
+
if (addedDirs.length === 0) {
|
|
113
|
+
ctx.ui.setWidget("add-dir", undefined);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
ctx.ui.setWidget("add-dir", (_tui, theme) => ({
|
|
118
|
+
dispose() {},
|
|
119
|
+
invalidate() {},
|
|
120
|
+
render(width: number): string[] {
|
|
121
|
+
const renderWidth = Math.max(1, width);
|
|
122
|
+
const prefix = theme.fg("accent", "📂");
|
|
123
|
+
const count = theme.fg("muted", ` ${addedDirs.length} external dir${addedDirs.length === 1 ? "" : "s"}`);
|
|
124
|
+
const separator = theme.fg("dim", " | ");
|
|
125
|
+
const suffix = theme.fg("dim", " (/dir-ls to manage)");
|
|
126
|
+
const labels = addedDirs.map((dir) => theme.fg("text", dir.label)).join(theme.fg("dim", ", "));
|
|
127
|
+
const fullLine = ` ${prefix}${count}${separator}${labels}${suffix}`;
|
|
128
|
+
|
|
129
|
+
if (visibleWidth(fullLine) <= renderWidth) return [fullLine];
|
|
130
|
+
const withoutLabels = ` ${prefix}${count}${separator}`;
|
|
131
|
+
const available = renderWidth - visibleWidth(withoutLabels) - visibleWidth(suffix);
|
|
132
|
+
if (available > 5) return [`${withoutLabels}${truncateToWidth(labels, available, "…")}${suffix}`];
|
|
133
|
+
return [truncateToWidth(` ${prefix}${count}`, renderWidth, "…")];
|
|
134
|
+
},
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function reconstructState(ctx: ExtensionContext): void {
|
|
139
|
+
currentCwd = ctx.cwd;
|
|
140
|
+
const stateEntry = [...ctx.sessionManager.getBranch()]
|
|
141
|
+
.reverse()
|
|
142
|
+
.find((entry) => entry.type === "custom" && entry.customType === STATE_TYPE);
|
|
143
|
+
addedDirs = stateEntry?.type === "custom" ? readState(stateEntry.data) : [];
|
|
144
|
+
updateWidget(ctx);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function persistState(): void {
|
|
148
|
+
pi.appendEntry(STATE_TYPE, { dirs: addedDirs.map((dir) => ({ ...dir })) });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function addDir(
|
|
152
|
+
dirPath: string,
|
|
153
|
+
cwd: string,
|
|
154
|
+
ctx: ExtensionContext,
|
|
155
|
+
): { ok: boolean; message: string; hasNewSkills: boolean; absolutePath?: string; context?: DirContext } {
|
|
156
|
+
const input = cleanPath(dirPath);
|
|
157
|
+
if (!input) return { ok: false, message: "Directory path must not be blank.", hasNewSkills: false };
|
|
158
|
+
|
|
159
|
+
const absolutePath = resolveDir(input, cwd);
|
|
160
|
+
if (!dirExists(absolutePath)) {
|
|
161
|
+
return { ok: false, message: `Directory does not exist: ${absolutePath}`, hasNewSkills: false };
|
|
162
|
+
}
|
|
163
|
+
if (addedDirs.some((dir) => dir.absolutePath === absolutePath)) {
|
|
164
|
+
return { ok: false, message: `Already added: ${absolutePath}`, hasNewSkills: false };
|
|
165
|
+
}
|
|
166
|
+
if (isWithinDir(resolveDir(cwd, cwd), absolutePath)) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
message: "Directory is already in current working directory scope.",
|
|
170
|
+
hasNewSkills: false,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const context = scanDirContext(absolutePath);
|
|
175
|
+
const label = basename(absolutePath) || absolutePath;
|
|
176
|
+
addedDirs.push({ absolutePath, label, addedAt: Date.now() });
|
|
177
|
+
persistState();
|
|
178
|
+
updateWidget(ctx);
|
|
179
|
+
|
|
180
|
+
const hasNewSkills = context.skills.size > 0;
|
|
181
|
+
return {
|
|
182
|
+
ok: true,
|
|
183
|
+
message: `Added ${label} (${absolutePath}).${contextSummary(context)}`,
|
|
184
|
+
hasNewSkills,
|
|
185
|
+
absolutePath,
|
|
186
|
+
context,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function removeDir(absolutePath: string, ctx: ExtensionContext): { ok: boolean; message: string } {
|
|
191
|
+
const index = addedDirs.findIndex((dir) => dir.absolutePath === absolutePath);
|
|
192
|
+
if (index < 0) return { ok: false, message: `Not found: ${absolutePath}` };
|
|
193
|
+
|
|
194
|
+
const [removed] = addedDirs.splice(index, 1);
|
|
195
|
+
persistState();
|
|
196
|
+
updateWidget(ctx);
|
|
197
|
+
return { ok: true, message: `Removed ${removed!.label} (${removed!.absolutePath}). Reloading resources...` };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
pi.on("resources_discover", (event) => {
|
|
201
|
+
if (event.cwd !== currentCwd || addedDirs.length === 0) return;
|
|
202
|
+
const skillPaths = collectSkillPaths(addedDirs);
|
|
203
|
+
return skillPaths.length > 0 ? { skillPaths } : undefined;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
|
|
207
|
+
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
|
|
208
|
+
|
|
209
|
+
pi.on("before_agent_start", async (event) => {
|
|
210
|
+
if (addedDirs.length === 0) return;
|
|
211
|
+
return { systemPrompt: event.systemPrompt + buildContextInjection(addedDirs) };
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
pi.registerCommand("dir-add", {
|
|
215
|
+
description: "Add an external directory to this session",
|
|
216
|
+
handler: async (args, ctx) => {
|
|
217
|
+
let inputPath = args?.trim();
|
|
218
|
+
if (!inputPath) {
|
|
219
|
+
const prompted = await ctx.ui.input("Directory path:", "");
|
|
220
|
+
if (!prompted) return;
|
|
221
|
+
inputPath = prompted;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const result = addDir(inputPath, ctx.cwd, ctx);
|
|
225
|
+
const message = result.ok && result.hasNewSkills ? `${result.message} Reloading to register skills...` : result.message;
|
|
226
|
+
ctx.ui.notify(message, result.ok ? "info" : "error");
|
|
227
|
+
if (result.ok && result.hasNewSkills) await ctx.reload();
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
pi.registerCommand("dir-ls", {
|
|
232
|
+
description: "List external directories and select one to remove",
|
|
233
|
+
handler: async (_args, ctx) => {
|
|
234
|
+
if (addedDirs.length === 0) {
|
|
235
|
+
ctx.ui.notify("No external directories added. Use /dir-add <path> to add one.", "info");
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const choices = addedDirs.map((dir) => `${dir.label} - ${dir.absolutePath}`);
|
|
240
|
+
const selected = await ctx.ui.select("External directories — select one to remove:", choices);
|
|
241
|
+
const selectedIndex = selected === undefined ? -1 : choices.indexOf(selected);
|
|
242
|
+
const absolutePath = selectedIndex >= 0 ? addedDirs[selectedIndex]?.absolutePath : undefined;
|
|
243
|
+
if (!absolutePath) return;
|
|
244
|
+
|
|
245
|
+
const result = removeDir(absolutePath, ctx);
|
|
246
|
+
ctx.ui.notify(result.message, result.ok ? "info" : "error");
|
|
247
|
+
if (result.ok) await ctx.reload();
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
pi.registerTool({
|
|
252
|
+
name: "add_directory",
|
|
253
|
+
label: "Add Directory",
|
|
254
|
+
description:
|
|
255
|
+
"Add an external directory to this session so its AGENTS.md, CLAUDE.md, and skills are loaded into context. " +
|
|
256
|
+
"Use this when you need to reference or work with code outside the current working directory.",
|
|
257
|
+
promptSnippet: "Add an external directory to this session (loads its AGENTS.md, skills, etc.)",
|
|
258
|
+
promptGuidelines: [
|
|
259
|
+
"Use add_directory when you need context from another project or directory outside cwd.",
|
|
260
|
+
"The directory's AGENTS.md and CLAUDE.md are returned now and injected into future system prompts.",
|
|
261
|
+
"After adding, you can read/edit/write files in the external directory using absolute paths.",
|
|
262
|
+
],
|
|
263
|
+
parameters: AddDirectoryParams,
|
|
264
|
+
|
|
265
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
266
|
+
const inputPath = cleanPath(params.path);
|
|
267
|
+
const result = addDir(inputPath, ctx.cwd, ctx);
|
|
268
|
+
if (!result.ok) throw new Error(result.message);
|
|
269
|
+
|
|
270
|
+
const absolutePath = result.absolutePath!;
|
|
271
|
+
const context = result.context!;
|
|
272
|
+
const response: string[] = [result.message];
|
|
273
|
+
if (context.agentsMd !== null) {
|
|
274
|
+
response.push(`\nAGENTS.md instructions from ${absolutePath}:\n${context.agentsMd}`);
|
|
275
|
+
}
|
|
276
|
+
if (context.claudeMd !== null) {
|
|
277
|
+
response.push(`\nCLAUDE.md instructions from ${absolutePath}:\n${context.claudeMd}`);
|
|
278
|
+
}
|
|
279
|
+
if (context.skills.size > 0) {
|
|
280
|
+
response.push(`\nDiscovered skills: ${[...context.skills.keys()].join(", ")}`);
|
|
281
|
+
response.push("Run /reload to register skills as /skill:name commands.");
|
|
282
|
+
}
|
|
283
|
+
response.push(`\nYou can now access files at: ${absolutePath}`);
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
content: [{ type: "text", text: response.join("\n") }],
|
|
287
|
+
details: contextDetails(context, absolutePath),
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
renderCall(args, theme) {
|
|
292
|
+
let text = theme.fg("toolTitle", theme.bold("add_directory "));
|
|
293
|
+
text += theme.fg("accent", cleanPath(args.path));
|
|
294
|
+
if (args.reason) text += theme.fg("dim", ` (${args.reason})`);
|
|
295
|
+
return new Text(text, 0, 0);
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
renderResult(result, { expanded }, theme) {
|
|
299
|
+
const details = result.details as AddDirectoryDetails | undefined;
|
|
300
|
+
if (!details) {
|
|
301
|
+
const content = result.content[0];
|
|
302
|
+
const text = content?.type === "text" ? content.text : "Done";
|
|
303
|
+
return new Text(theme.fg("success", `✓ ${text}`), 0, 0);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const parts = [theme.fg("success", `✓ Added ${basename(details.directory)}`)];
|
|
307
|
+
const badges: string[] = [];
|
|
308
|
+
if (details.hasAgentsMd) badges.push(theme.fg("accent", "AGENTS.md"));
|
|
309
|
+
if (details.hasClaudeMd) badges.push(theme.fg("accent", "CLAUDE.md"));
|
|
310
|
+
if (details.skillCount > 0) badges.push(theme.fg("warning", `${details.skillCount} skills`));
|
|
311
|
+
if (badges.length > 0) parts.push(theme.fg("dim", " | ") + badges.join(theme.fg("dim", ", ")));
|
|
312
|
+
if (expanded && details.skillNames.length > 0) {
|
|
313
|
+
parts.push("\n" + theme.fg("muted", " Skills: ") + details.skillNames.map((name) => theme.fg("text", name)).join(", "));
|
|
314
|
+
}
|
|
315
|
+
return new Text(parts.join(""), 0, 0);
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
pi.registerTool({
|
|
320
|
+
name: "search_external_files",
|
|
321
|
+
label: "Search External Files",
|
|
322
|
+
description:
|
|
323
|
+
"Search for files across all external directories added to this session. " +
|
|
324
|
+
"Use this when you need to find files outside the current working directory.",
|
|
325
|
+
promptSnippet: "Search for files across all added external directories by name pattern",
|
|
326
|
+
promptGuidelines: [
|
|
327
|
+
"Use search_external_files when you need to find a file in an external directory but do not know its exact path.",
|
|
328
|
+
"Supports glob-style patterns like '*.ts', '**/*.test.js', and 'src/**/*.rb'.",
|
|
329
|
+
"Returns matching file paths with their parent directory labels.",
|
|
330
|
+
],
|
|
331
|
+
parameters: SearchExternalFilesParams,
|
|
332
|
+
|
|
333
|
+
async execute(_toolCallId, params, signal) {
|
|
334
|
+
if (addedDirs.length === 0) {
|
|
335
|
+
throw new Error("No external directories added. Use /dir-add or add_directory first.");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const pattern = cleanPath(params.pattern);
|
|
339
|
+
const limit = maxResults(params.maxResults);
|
|
340
|
+
const results: Array<{ dir: string; label: string; files: string[] }> = [];
|
|
341
|
+
let totalFound = 0;
|
|
342
|
+
|
|
343
|
+
for (const dir of addedDirs) {
|
|
344
|
+
signal?.throwIfAborted();
|
|
345
|
+
if (!dirExists(dir.absolutePath)) continue;
|
|
346
|
+
const remaining = limit - totalFound;
|
|
347
|
+
if (remaining <= 0) break;
|
|
348
|
+
|
|
349
|
+
const files = await findFiles(dir.absolutePath, pattern, remaining, signal);
|
|
350
|
+
if (files.length > 0) {
|
|
351
|
+
results.push({ dir: dir.absolutePath, label: dir.label, files });
|
|
352
|
+
totalFound += files.length;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
signal?.throwIfAborted();
|
|
357
|
+
if (totalFound === 0) {
|
|
358
|
+
return {
|
|
359
|
+
content: [
|
|
360
|
+
{
|
|
361
|
+
type: "text",
|
|
362
|
+
text: `No files matching "${pattern}" found in ${addedDirs.length} external director${addedDirs.length === 1 ? "y" : "ies"}.`,
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
details: { totalFound: 0, pattern, dirCount: 0 } satisfies SearchDetails,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const lines = [`Found ${totalFound} file(s) matching "${pattern}":\n`];
|
|
370
|
+
for (const result of results) {
|
|
371
|
+
lines.push(`📂 ${result.label} (${result.dir}):`);
|
|
372
|
+
for (const file of result.files) lines.push(` ${file}`);
|
|
373
|
+
lines.push("");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
378
|
+
details: { totalFound, pattern, dirCount: results.length } satisfies SearchDetails,
|
|
379
|
+
};
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
renderCall(args, theme) {
|
|
383
|
+
let text = theme.fg("toolTitle", theme.bold("search_external_files "));
|
|
384
|
+
text += theme.fg("accent", `"${cleanPath(args.pattern)}"`);
|
|
385
|
+
text += theme.fg("dim", ` across ${addedDirs.length} dir(s)`);
|
|
386
|
+
return new Text(text, 0, 0);
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
renderResult(result, { expanded }, theme) {
|
|
390
|
+
const details = result.details as SearchDetails | undefined;
|
|
391
|
+
if (!details || details.totalFound === 0) {
|
|
392
|
+
const content = result.content[0];
|
|
393
|
+
const text = content?.type === "text" ? content.text : "No results";
|
|
394
|
+
return new Text(theme.fg("muted", text), 0, 0);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let text = theme.fg("success", `✓ ${details.totalFound} file(s)`);
|
|
398
|
+
text += theme.fg("dim", ` matching "${details.pattern}" in ${details.dirCount} dir(s)`);
|
|
399
|
+
if (expanded) {
|
|
400
|
+
const content = result.content[0];
|
|
401
|
+
if (content?.type === "text") text += `\n${theme.fg("muted", content.text)}`;
|
|
402
|
+
}
|
|
403
|
+
return new Text(text, 0, 0);
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@henryqw/pi-add-dir",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Add external directories to a Pi session with context, skills, and file search.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"external-directory",
|
|
9
|
+
"context",
|
|
10
|
+
"skills"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.19.0"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"files": [
|
|
18
|
+
"extensions",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test test/*.test.ts",
|
|
24
|
+
"typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/*.ts test/*.test.ts",
|
|
25
|
+
"pack:check": "npm pack --dry-run"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@earendil-works/pi-coding-agent": ">=0.84.1",
|
|
29
|
+
"@earendil-works/pi-tui": ">=0.84.1",
|
|
30
|
+
"typebox": "*"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/HenryQW/pi-packages.git",
|
|
35
|
+
"directory": "packages/pi-add-dir"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/HenryQW/pi-packages/issues"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"pi": {
|
|
44
|
+
"extensions": [
|
|
45
|
+
"./extensions/add-dir.ts"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
}
|