Skip to content

Commit 57419ce

Browse files
authored
feat(pi-add-dir): add external directory extension (#69)
1 parent 91b81bb commit 57419ce

12 files changed

Lines changed: 1030 additions & 0 deletions

File tree

CONTEXT-MAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
| Context | Domain glossary | Scope |
44
| --- | --- | --- |
5+
| `pi-add-dir` | [`packages/pi-add-dir/CONTEXT.md`](packages/pi-add-dir/CONTEXT.md) | External directories, context files, skills, and file search |
56
| `pi-ask-question` | [`packages/pi-ask-question/CONTEXT.md`](packages/pi-ask-question/CONTEXT.md) | Interactive user questions with predefined or custom answers |
67
| `pi-auto-compact` | [`packages/pi-auto-compact/CONTEXT.md`](packages/pi-auto-compact/CONTEXT.md) | Proactive Pi context compaction and task resumption |
78
| `pi-auto-dag` | [`packages/pi-auto-dag/CONTEXT.md`](packages/pi-auto-dag/CONTEXT.md) | Dependency-aware local delivery work and worker execution |

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Public Pi packages under `@henryqw` scope.
77
| Package | Install | Purpose |
88
| --- | --- | --- |
99
| [`@henryqw/pi-ask-question`](./packages/pi-ask-question) | `pi install npm:@henryqw/pi-ask-question` | Ask user one interactive multiple-choice or free-text question. |
10+
| [`@henryqw/pi-add-dir`](./packages/pi-add-dir) | `pi install npm:@henryqw/pi-add-dir` | Add external directories with context, skills, and file search. |
1011
| [`@henryqw/pi-auto-compact`](./packages/pi-auto-compact) | `pi install npm:@henryqw/pi-auto-compact` | Compact context at the set threshold and resume current task. |
1112
| [`@henryqw/pi-auto-dag`](./packages/pi-auto-dag) | `pi install npm:@henryqw/pi-auto-dag` | Execute an approved local Delivery Graph through Pi workers. |
1213
| [`@henryqw/pi-herdr`](./packages/pi-herdr) | `npm install @henryqw/pi-herdr` | Run Herdr CLI commands through a shared thin client. |

package-lock.json

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"pi": {
1717
"extensions": [
1818
"./packages/pi-ask-question/extensions/ask-question.ts",
19+
"./packages/pi-add-dir/extensions/add-dir.ts",
1920
"./packages/pi-auto-compact/extensions",
2021
"./packages/pi-auto-dag/extensions/auto-dag.ts",
2122
"./packages/pi-model-thinking/extensions",

packages/pi-add-dir/AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Agent instructions
2+
3+
OBSIDIAN_PROJECT=${OBSIDIAN_ROOT}/projects/Pi/Packages/pi-add-dir

packages/pi-add-dir/CONTEXT.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Pi Add Dir
2+
3+
**External directory**: Directory outside current Pi working directory added to active session.
4+
_Avoid_: Project root, current working directory
5+
6+
**External context**: Root `AGENTS.md` / `CLAUDE.md` files from an external directory injected into Pi system context.
7+
_Avoid_: File search result, session transcript
8+
9+
**External skill**: Skill found in `.pi/skills`, `.agents/skills`, or `.claude/skills` under an external directory and registered as `/skill:<name>`.
10+
_Avoid_: Local skill, prompt text

packages/pi-add-dir/LICENSE

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

packages/pi-add-dir/README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
```
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)