@firstpick/pi-extension-cd 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/index.ts +700 -0
  4. package/package.json +34 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Firstpick
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @firstpick/pi-extension-cd
2
+
3
+ Pi extension that adds `/cd` for changing the active Pi session working directory with ranked suggestions, persistent history, and aliases.
4
+
5
+ ## Features
6
+
7
+ - `/cd` opens a picker with sane suggestions: aliases, previous directories, `..`, `~`, and child directories.
8
+ - `/cd <dir>` changes to a directory and preserves the conversation by forking the session into the target cwd.
9
+ - Successful directory changes are saved to `~/.pi/agent/state/cd-history.json` and ranked higher next time.
10
+ - `/cd --add <name> [dir]` creates aliases so `/cd <name>` jumps fast.
11
+ - Argument completions suggest aliases/history/directories while typing `/cd ...`.
12
+
13
+ ## Commands
14
+
15
+ ```text
16
+ /cd [dir|alias]
17
+ /cd
18
+ /cd --add <name> [dir]
19
+ /cd --remove <name>
20
+ /cd --list
21
+ /cd --status
22
+ /cd --clear-history
23
+ /cd-refresh
24
+ ```
25
+
26
+ Examples:
27
+
28
+ ```text
29
+ /cd ..
30
+ /cd ~/code/my-app
31
+ /cd --add npm /home/firstpick/npm-packages
32
+ /cd npm
33
+ /cd --remove npm
34
+ ```
35
+
36
+ ## Install / test locally
37
+
38
+ From this repository:
39
+
40
+ ```bash
41
+ pi -e ./pi-extension-cd
42
+ ```
43
+
44
+ Or install as a local Pi package:
45
+
46
+ ```bash
47
+ pi install ./pi-extension-cd
48
+ ```
49
+
50
+ ## Configuration
51
+
52
+ - `PI_CD_HISTORY_STORE_PATH=/path/to/store.json` overrides the history/alias store.
53
+ - `PI_CODING_AGENT_DIR=/path/to/agent-dir` changes the default base directory used for the store.
54
+
55
+ ## Notes
56
+
57
+ Pi binds cwd to sessions. This extension implements `/cd` by creating a target-cwd session and switching to it. When a persisted current session exists, the target session is forked from it so conversation context is preserved.
package/index.ts ADDED
@@ -0,0 +1,700 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { SessionManager, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
5
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
6
+
7
+ type CdHistoryEntry = {
8
+ path: string;
9
+ uses: number;
10
+ firstUsed: number;
11
+ lastUsed: number;
12
+ };
13
+
14
+ type CdStore = {
15
+ version: 1;
16
+ aliases: Record<string, string>;
17
+ history: CdHistoryEntry[];
18
+ };
19
+
20
+ type SuggestionSource = "alias" | "history" | "child" | "special";
21
+
22
+ type CdSuggestion = {
23
+ value: string;
24
+ label: string;
25
+ targetPath: string;
26
+ description: string;
27
+ source: SuggestionSource;
28
+ score: number;
29
+ };
30
+
31
+ const STORE_VERSION = 1;
32
+ const MAX_HISTORY_ENTRIES = 200;
33
+ const MAX_SUGGESTIONS = 24;
34
+ const MAX_CHILDREN_SCANNED = 300;
35
+ const STATUS_KEY = "cd-history";
36
+
37
+ const RESERVED_ALIAS_NAMES = new Set([
38
+ ".",
39
+ "..",
40
+ "~",
41
+ "--add",
42
+ "--clear-history",
43
+ "--help",
44
+ "--list",
45
+ "--remove",
46
+ "--rm",
47
+ "--status",
48
+ ]);
49
+
50
+ function getAgentDir(): string {
51
+ const configured = process.env.PI_CODING_AGENT_DIR?.trim();
52
+ return configured ? path.resolve(expandTilde(configured)) : path.join(os.homedir(), ".pi", "agent");
53
+ }
54
+
55
+ function getStorePath(): string {
56
+ const configured = process.env.PI_CD_HISTORY_STORE_PATH?.trim();
57
+ return configured ? path.resolve(expandTilde(configured)) : path.join(getAgentDir(), "state", "cd-history.json");
58
+ }
59
+
60
+ function emptyStore(): CdStore {
61
+ return { version: STORE_VERSION, aliases: {}, history: [] };
62
+ }
63
+
64
+ function expandTilde(input: string): string {
65
+ if (input === "~") return os.homedir();
66
+ if (input.startsWith(`~${path.sep}`) || input.startsWith("~/")) {
67
+ return path.join(os.homedir(), input.slice(2));
68
+ }
69
+ return input;
70
+ }
71
+
72
+ function canonicalPath(input: string, baseDir?: string): string {
73
+ const expanded = expandTilde(input.trim());
74
+ const resolved = path.resolve(baseDir ?? process.cwd(), expanded);
75
+ try {
76
+ return fs.realpathSync.native(resolved);
77
+ } catch {
78
+ return resolved;
79
+ }
80
+ }
81
+
82
+ function isDirectory(input: string): boolean {
83
+ try {
84
+ return fs.statSync(input).isDirectory();
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ function normalizeDirectory(input: string, baseDir?: string): string | undefined {
91
+ const resolved = canonicalPath(input, baseDir);
92
+ return isDirectory(resolved) ? resolved : undefined;
93
+ }
94
+
95
+ function formatPath(input: string): string {
96
+ const home = os.homedir();
97
+ const resolvedHome = path.resolve(home);
98
+ const resolved = path.resolve(input);
99
+ if (resolved === resolvedHome) return "~";
100
+ if (resolved.startsWith(`${resolvedHome}${path.sep}`)) {
101
+ return `~/${path.relative(resolvedHome, resolved).split(path.sep).join("/")}`;
102
+ }
103
+ return resolved.split(path.sep).join(path.sep);
104
+ }
105
+
106
+ function formatAge(timestamp: number): string {
107
+ const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
108
+ if (seconds < 60) return "now";
109
+ const minutes = Math.floor(seconds / 60);
110
+ if (minutes < 60) return `${minutes}m ago`;
111
+ const hours = Math.floor(minutes / 60);
112
+ if (hours < 24) return `${hours}h ago`;
113
+ const days = Math.floor(hours / 24);
114
+ if (days < 30) return `${days}d ago`;
115
+ const months = Math.floor(days / 30);
116
+ if (months < 12) return `${months}mo ago`;
117
+ return `${Math.floor(months / 12)}y ago`;
118
+ }
119
+
120
+ function readStore(storePath: string): CdStore {
121
+ if (!fs.existsSync(storePath)) return emptyStore();
122
+
123
+ try {
124
+ const parsed = JSON.parse(fs.readFileSync(storePath, "utf8")) as unknown;
125
+ if (!parsed || typeof parsed !== "object") return emptyStore();
126
+
127
+ const store = emptyStore();
128
+ const rawAliases = (parsed as { aliases?: unknown }).aliases;
129
+ if (rawAliases && typeof rawAliases === "object" && !Array.isArray(rawAliases)) {
130
+ for (const [name, target] of Object.entries(rawAliases)) {
131
+ if (!isValidAliasName(name) || typeof target !== "string") continue;
132
+ const normalized = normalizeDirectory(target);
133
+ if (normalized) store.aliases[name] = normalized;
134
+ }
135
+ }
136
+
137
+ const rawHistory = (parsed as { history?: unknown }).history;
138
+ if (Array.isArray(rawHistory)) {
139
+ for (const item of rawHistory) {
140
+ if (!item || typeof item !== "object") continue;
141
+ const record = item as { path?: unknown; uses?: unknown; firstUsed?: unknown; lastUsed?: unknown };
142
+ if (typeof record.path !== "string") continue;
143
+ const normalized = normalizeDirectory(record.path);
144
+ if (!normalized) continue;
145
+ const uses = typeof record.uses === "number" && Number.isFinite(record.uses) ? Math.max(1, Math.floor(record.uses)) : 1;
146
+ const firstUsed = typeof record.firstUsed === "number" && Number.isFinite(record.firstUsed) ? record.firstUsed : Date.now();
147
+ const lastUsed = typeof record.lastUsed === "number" && Number.isFinite(record.lastUsed) ? record.lastUsed : firstUsed;
148
+ store.history.push({ path: normalized, uses, firstUsed, lastUsed });
149
+ }
150
+ }
151
+
152
+ return compactStore(store);
153
+ } catch {
154
+ return emptyStore();
155
+ }
156
+ }
157
+
158
+ function writeStore(storePath: string, store: CdStore): void {
159
+ try {
160
+ fs.mkdirSync(path.dirname(storePath), { recursive: true });
161
+ const compacted = compactStore(store);
162
+ const tempPath = `${storePath}.${process.pid}.${Date.now()}.tmp`;
163
+ fs.writeFileSync(tempPath, `${JSON.stringify(compacted, null, 2)}\n`, "utf8");
164
+ fs.renameSync(tempPath, storePath);
165
+ } catch {
166
+ // Directory changes should still work if persistence fails.
167
+ }
168
+ }
169
+
170
+ function compactStore(store: CdStore): CdStore {
171
+ const byPath = new Map<string, CdHistoryEntry>();
172
+ for (const entry of store.history) {
173
+ const normalized = normalizeDirectory(entry.path);
174
+ if (!normalized) continue;
175
+ const existing = byPath.get(normalized);
176
+ if (!existing) {
177
+ byPath.set(normalized, { ...entry, path: normalized });
178
+ continue;
179
+ }
180
+ existing.uses += Math.max(1, entry.uses);
181
+ existing.firstUsed = Math.min(existing.firstUsed, entry.firstUsed);
182
+ existing.lastUsed = Math.max(existing.lastUsed, entry.lastUsed);
183
+ }
184
+
185
+ const aliases: Record<string, string> = {};
186
+ for (const [name, target] of Object.entries(store.aliases)) {
187
+ if (!isValidAliasName(name)) continue;
188
+ const normalized = normalizeDirectory(target);
189
+ if (normalized) aliases[name] = normalized;
190
+ }
191
+
192
+ const history = Array.from(byPath.values())
193
+ .sort((a, b) => b.lastUsed - a.lastUsed || b.uses - a.uses || a.path.localeCompare(b.path))
194
+ .slice(0, MAX_HISTORY_ENTRIES);
195
+
196
+ return { version: STORE_VERSION, aliases, history };
197
+ }
198
+
199
+ function recordVisit(store: CdStore, targetPath: string): CdStore {
200
+ const normalized = normalizeDirectory(targetPath);
201
+ if (!normalized) return store;
202
+
203
+ const now = Date.now();
204
+ const next = compactStore(store);
205
+ const existing = next.history.find((entry) => entry.path === normalized);
206
+ if (existing) {
207
+ existing.uses += 1;
208
+ existing.lastUsed = now;
209
+ } else {
210
+ next.history.unshift({ path: normalized, uses: 1, firstUsed: now, lastUsed: now });
211
+ }
212
+ return compactStore(next);
213
+ }
214
+
215
+ function isValidAliasName(name: string): boolean {
216
+ if (RESERVED_ALIAS_NAMES.has(name)) return false;
217
+ if (name.includes("/") || name.includes("\\")) return false;
218
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name);
219
+ }
220
+
221
+ function splitArgs(input: string): string[] {
222
+ const words: string[] = [];
223
+ let current = "";
224
+ let quote: "'" | '"' | undefined;
225
+ let escaped = false;
226
+
227
+ for (const char of input) {
228
+ if (escaped) {
229
+ current += char;
230
+ escaped = false;
231
+ continue;
232
+ }
233
+
234
+ if (char === "\\" && quote !== "'") {
235
+ escaped = true;
236
+ continue;
237
+ }
238
+
239
+ if (quote) {
240
+ if (char === quote) quote = undefined;
241
+ else current += char;
242
+ continue;
243
+ }
244
+
245
+ if (char === "'" || char === '"') {
246
+ quote = char;
247
+ continue;
248
+ }
249
+
250
+ if (/\s/.test(char)) {
251
+ if (current) {
252
+ words.push(current);
253
+ current = "";
254
+ }
255
+ continue;
256
+ }
257
+
258
+ current += char;
259
+ }
260
+
261
+ if (escaped) current += "\\";
262
+ if (current) words.push(current);
263
+ return words;
264
+ }
265
+
266
+ function fuzzyScore(text: string, query: string): number {
267
+ const lowerText = text.toLowerCase();
268
+ const lowerQuery = query.toLowerCase();
269
+ if (!lowerQuery) return 0;
270
+
271
+ let textIndex = 0;
272
+ let gapPenalty = 0;
273
+ for (const queryChar of lowerQuery) {
274
+ const found = lowerText.indexOf(queryChar, textIndex);
275
+ if (found === -1) return Number.NEGATIVE_INFINITY;
276
+ gapPenalty += found - textIndex;
277
+ textIndex = found + 1;
278
+ }
279
+
280
+ return 350 - gapPenalty;
281
+ }
282
+
283
+ function matchScore(suggestion: CdSuggestion, query: string): number {
284
+ const trimmed = query.trim().toLowerCase();
285
+ if (!trimmed) return suggestion.score;
286
+
287
+ const basename = path.basename(suggestion.targetPath).toLowerCase();
288
+ const fields = [suggestion.value, suggestion.label, basename, formatPath(suggestion.targetPath), suggestion.targetPath].map((value) => value.toLowerCase());
289
+
290
+ if (fields.some((field) => field === trimmed)) return suggestion.score + 3000;
291
+ if (suggestion.value.toLowerCase().startsWith(trimmed)) return suggestion.score + 2200;
292
+ if (basename.startsWith(trimmed)) return suggestion.score + 1800;
293
+ if (fields.some((field) => field.includes(trimmed))) return suggestion.score + 1200;
294
+
295
+ const bestFuzzy = Math.max(...fields.map((field) => fuzzyScore(field, trimmed)));
296
+ return Number.isFinite(bestFuzzy) ? suggestion.score + bestFuzzy : Number.NEGATIVE_INFINITY;
297
+ }
298
+
299
+ function addSuggestion(map: Map<string, CdSuggestion>, suggestion: CdSuggestion): void {
300
+ const key = `${suggestion.source}:${suggestion.value}:${suggestion.targetPath}`;
301
+ const existing = map.get(key);
302
+ if (!existing || suggestion.score > existing.score) map.set(key, suggestion);
303
+ }
304
+
305
+ function readChildDirectories(cwd: string): CdSuggestion[] {
306
+ let entries: fs.Dirent[];
307
+ try {
308
+ entries = fs.readdirSync(cwd, { withFileTypes: true });
309
+ } catch {
310
+ return [];
311
+ }
312
+
313
+ return entries
314
+ .filter((entry) => entry.isDirectory())
315
+ .sort((a, b) => a.name.localeCompare(b.name))
316
+ .slice(0, MAX_CHILDREN_SCANNED)
317
+ .map((entry) => {
318
+ const targetPath = canonicalPath(path.join(cwd, entry.name));
319
+ return {
320
+ value: entry.name,
321
+ label: entry.name,
322
+ targetPath,
323
+ description: formatPath(targetPath),
324
+ source: "child" as const,
325
+ score: entry.name.startsWith(".") ? 180 : 260,
326
+ };
327
+ });
328
+ }
329
+
330
+ function buildSuggestions(cwd: string, store: CdStore, query = ""): CdSuggestion[] {
331
+ const suggestions = new Map<string, CdSuggestion>();
332
+ const normalizedCwd = canonicalPath(cwd);
333
+ const parent = path.dirname(normalizedCwd);
334
+ const home = canonicalPath(os.homedir());
335
+ const now = Date.now();
336
+
337
+ addSuggestion(suggestions, {
338
+ value: "..",
339
+ label: "..",
340
+ targetPath: parent,
341
+ description: formatPath(parent),
342
+ source: "special",
343
+ score: 360,
344
+ });
345
+ addSuggestion(suggestions, {
346
+ value: "~",
347
+ label: "~",
348
+ targetPath: home,
349
+ description: formatPath(home),
350
+ source: "special",
351
+ score: 320,
352
+ });
353
+
354
+ for (const [alias, targetPath] of Object.entries(store.aliases)) {
355
+ if (!isDirectory(targetPath)) continue;
356
+ addSuggestion(suggestions, {
357
+ value: alias,
358
+ label: alias,
359
+ targetPath,
360
+ description: `alias → ${formatPath(targetPath)}`,
361
+ source: "alias",
362
+ score: 1200,
363
+ });
364
+ }
365
+
366
+ for (const entry of store.history) {
367
+ if (!isDirectory(entry.path)) continue;
368
+ const ageDays = Math.max(0, (now - entry.lastUsed) / 86_400_000);
369
+ const recency = Math.max(0, 120 - ageDays * 4);
370
+ addSuggestion(suggestions, {
371
+ value: formatPath(entry.path),
372
+ label: path.basename(entry.path) || entry.path,
373
+ targetPath: entry.path,
374
+ description: `${formatPath(entry.path)} · ${entry.uses} use${entry.uses === 1 ? "" : "s"} · ${formatAge(entry.lastUsed)}`,
375
+ source: "history",
376
+ score: 520 + entry.uses * 24 + recency,
377
+ });
378
+ }
379
+
380
+ for (const child of readChildDirectories(normalizedCwd)) {
381
+ addSuggestion(suggestions, child);
382
+ }
383
+
384
+ return Array.from(suggestions.values())
385
+ .map((suggestion) => ({ suggestion, score: matchScore(suggestion, query) }))
386
+ .filter((entry) => Number.isFinite(entry.score))
387
+ .sort((a, b) => b.score - a.score || a.suggestion.label.localeCompare(b.suggestion.label))
388
+ .slice(0, MAX_SUGGESTIONS)
389
+ .map((entry) => entry.suggestion);
390
+ }
391
+
392
+ function quoteArg(value: string): string {
393
+ if (/^[^\s"'\\]+$/.test(value)) return value;
394
+ return `"${value.replace(/["\\]/g, (match) => `\\${match}`)}"`;
395
+ }
396
+
397
+ function suggestionsToAutocompleteItems(suggestions: CdSuggestion[]): AutocompleteItem[] {
398
+ return suggestions.map((suggestion) => ({
399
+ value: suggestion.value,
400
+ label: suggestion.label,
401
+ description: suggestion.description,
402
+ }));
403
+ }
404
+
405
+ function resolveTarget(rawArgs: string, cwd: string, store: CdStore): { targetPath?: string; suggestions: CdSuggestion[] } {
406
+ const target = rawArgs.trim();
407
+ if (!target || target === ".") return { targetPath: canonicalPath(cwd), suggestions: [] };
408
+
409
+ const aliasTarget = store.aliases[target];
410
+ if (aliasTarget && isDirectory(aliasTarget)) {
411
+ return { targetPath: aliasTarget, suggestions: [] };
412
+ }
413
+
414
+ const direct = normalizeDirectory(target, cwd);
415
+ if (direct) return { targetPath: direct, suggestions: [] };
416
+
417
+ const suggestions = buildSuggestions(cwd, store, target);
418
+ const exact = suggestions.find((suggestion) =>
419
+ suggestion.value === target ||
420
+ suggestion.label === target ||
421
+ formatPath(suggestion.targetPath) === target ||
422
+ suggestion.targetPath === target
423
+ );
424
+ if (exact) return { targetPath: exact.targetPath, suggestions };
425
+
426
+ return { suggestions };
427
+ }
428
+
429
+ function ensureEmptySessionFile(targetPath: string): string {
430
+ const sessionManager = SessionManager.create(targetPath);
431
+ const sessionPath = sessionManager.getSessionFile();
432
+ if (!sessionPath) throw new Error("Unable to create a persistent Pi session for target directory");
433
+
434
+ const internal = sessionManager as unknown as { fileEntries?: unknown[]; _rewriteFile?: () => void };
435
+ if (typeof internal._rewriteFile === "function") {
436
+ internal._rewriteFile();
437
+ return sessionPath;
438
+ }
439
+
440
+ const entries = internal.fileEntries;
441
+ if (!Array.isArray(entries) || entries.length === 0) {
442
+ throw new Error("Unable to initialize target Pi session header");
443
+ }
444
+ fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
445
+ fs.writeFileSync(sessionPath, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, { flag: "wx" });
446
+ return sessionPath;
447
+ }
448
+
449
+ function createTargetSession(currentSessionPath: string | undefined, targetPath: string): string {
450
+ if (currentSessionPath && fs.existsSync(currentSessionPath)) {
451
+ try {
452
+ const forked = SessionManager.forkFrom(currentSessionPath, targetPath);
453
+ const forkedPath = forked.getSessionFile();
454
+ if (forkedPath) return forkedPath;
455
+ } catch {
456
+ // Fall back to a clean target session if the current session file is absent/corrupt.
457
+ }
458
+ }
459
+
460
+ return ensureEmptySessionFile(targetPath);
461
+ }
462
+
463
+ async function selectTarget(ctx: ExtensionCommandContext, suggestions: CdSuggestion[], title: string): Promise<CdSuggestion | undefined> {
464
+ if (!ctx.hasUI || suggestions.length === 0) return undefined;
465
+
466
+ const items = suggestions.map((suggestion, index) => {
467
+ const prefix = suggestion.source === "alias" ? "★" : suggestion.source === "history" ? "↺" : suggestion.source === "special" ? "•" : "dir";
468
+ return `${prefix} ${suggestion.label} — ${suggestion.description} [${index + 1}]`;
469
+ });
470
+ const selected = await ctx.ui.select(title, items);
471
+ if (!selected) return undefined;
472
+ const index = items.indexOf(selected);
473
+ return index >= 0 ? suggestions[index] : undefined;
474
+ }
475
+
476
+ function helpText(): string {
477
+ return [
478
+ "/cd [dir|alias] change Pi session cwd, preserving conversation by forking into the target cwd",
479
+ "/cd pick from ranked aliases, history, parent/home, and child directories",
480
+ "/cd --add <name> [dir] add alias so /cd <name> jumps to dir (default: current cwd)",
481
+ "/cd --remove <name> remove an alias",
482
+ "/cd --list pick from aliases/history",
483
+ "/cd --status show store path and counts",
484
+ "/cd --clear-history clear learned directory history (aliases stay)",
485
+ ].join("\n");
486
+ }
487
+
488
+ async function changeDirectory(
489
+ ctx: ExtensionCommandContext,
490
+ targetPath: string,
491
+ getStore: () => CdStore,
492
+ setStore: (store: CdStore) => void,
493
+ ): Promise<void> {
494
+ await ctx.waitForIdle();
495
+
496
+ const normalizedTarget = normalizeDirectory(targetPath);
497
+ if (!normalizedTarget) {
498
+ ctx.ui.notify(`cd: not a directory: ${targetPath}`, "error");
499
+ return;
500
+ }
501
+
502
+ const current = canonicalPath(ctx.cwd);
503
+ if (normalizedTarget === current) {
504
+ const store = recordVisit(getStore(), normalizedTarget);
505
+ setStore(store);
506
+ ctx.ui.notify(`Already in ${formatPath(normalizedTarget)}`, "info");
507
+ return;
508
+ }
509
+
510
+ const currentSessionPath = ctx.sessionManager.getSessionFile();
511
+ let targetSessionPath: string;
512
+ try {
513
+ targetSessionPath = createTargetSession(currentSessionPath, normalizedTarget);
514
+ } catch (error) {
515
+ const message = error instanceof Error ? error.message : String(error);
516
+ ctx.ui.notify(`cd: failed to create target session: ${message}`, "error");
517
+ return;
518
+ }
519
+
520
+ setStore(recordVisit(getStore(), normalizedTarget));
521
+
522
+ const result = await ctx.switchSession(targetSessionPath, {
523
+ withSession: async (nextCtx) => {
524
+ nextCtx.ui.notify(`cd → ${formatPath(normalizedTarget)}`, "info");
525
+ nextCtx.ui.setStatus(STATUS_KEY, `cwd ${formatPath(normalizedTarget)}`);
526
+ },
527
+ });
528
+
529
+ if (result.cancelled) {
530
+ ctx.ui.notify(`cd cancelled: ${formatPath(normalizedTarget)}`, "warning");
531
+ }
532
+ }
533
+
534
+ function addAlias(store: CdStore, name: string, targetPath: string): CdStore {
535
+ if (!isValidAliasName(name)) {
536
+ throw new Error(`Invalid alias '${name}'. Use letters, numbers, dot, underscore, or dash; start with a letter/number.`);
537
+ }
538
+ const normalized = normalizeDirectory(targetPath);
539
+ if (!normalized) throw new Error(`Not a directory: ${targetPath}`);
540
+ return compactStore({ ...store, aliases: { ...store.aliases, [name]: normalized } });
541
+ }
542
+
543
+ function removeAlias(store: CdStore, name: string): CdStore {
544
+ const aliases = { ...store.aliases };
545
+ delete aliases[name];
546
+ return compactStore({ ...store, aliases });
547
+ }
548
+
549
+ export default function cdHistoryExtension(pi: ExtensionAPI) {
550
+ const storePath = getStorePath();
551
+ let store = readStore(storePath);
552
+ let currentCwd = canonicalPath(process.cwd());
553
+
554
+ const setStore = (nextStore: CdStore) => {
555
+ store = compactStore(nextStore);
556
+ writeStore(storePath, store);
557
+ };
558
+
559
+ const refreshStore = () => {
560
+ store = readStore(storePath);
561
+ };
562
+
563
+ pi.on("session_start", (_event, ctx) => {
564
+ currentCwd = canonicalPath(ctx.cwd);
565
+ refreshStore();
566
+ ctx.ui.setStatus(STATUS_KEY, `cwd ${formatPath(currentCwd)}`);
567
+ });
568
+
569
+ pi.registerCommand("cd", {
570
+ description: "Change Pi working directory with ranked history and aliases",
571
+ getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
572
+ refreshStore();
573
+ const trimmed = prefix.trimStart();
574
+ const words = splitArgs(trimmed);
575
+ const commandOptions = ["--add", "--remove", "--list", "--status", "--clear-history", "--help"];
576
+
577
+ if (trimmed.startsWith("--") && words.length <= 1 && !trimmed.endsWith(" ")) {
578
+ const filtered = commandOptions.filter((option) => option.startsWith(trimmed));
579
+ return filtered.length > 0 ? filtered.map((option) => ({ value: option, label: option })) : null;
580
+ }
581
+
582
+ if (words[0] === "--remove" || words[0] === "--rm") {
583
+ const option = words[0];
584
+ const partial = words[1] ?? "";
585
+ const items = Object.entries(store.aliases)
586
+ .filter(([name]) => name.startsWith(partial))
587
+ .map(([name, target]) => ({ value: `${option} ${name}`, label: name, description: formatPath(target) }));
588
+ return items.length > 0 ? items : null;
589
+ }
590
+
591
+ if (words[0] === "--add" && words[1] && (trimmed.endsWith(" ") || words.length >= 3)) {
592
+ const aliasName = words[1];
593
+ const pathPrefix = words.length >= 3 ? words.slice(2).join(" ") : "";
594
+ const items = buildSuggestions(currentCwd, store, pathPrefix).map((suggestion) => ({
595
+ value: `--add ${aliasName} ${quoteArg(formatPath(suggestion.targetPath))}`,
596
+ label: suggestion.label,
597
+ description: suggestion.description,
598
+ }));
599
+ return items.length > 0 ? items : null;
600
+ }
601
+
602
+ const items = suggestionsToAutocompleteItems(buildSuggestions(currentCwd, store, prefix));
603
+ return items.length > 0 ? items : null;
604
+ },
605
+ handler: async (args, ctx) => {
606
+ refreshStore();
607
+ currentCwd = canonicalPath(ctx.cwd);
608
+ const trimmed = args.trim();
609
+
610
+ if (trimmed === "--help" || trimmed === "-h") {
611
+ ctx.ui.notify(helpText(), "info");
612
+ return;
613
+ }
614
+
615
+ if (trimmed === "--status") {
616
+ ctx.ui.notify(
617
+ `cd-history: ${Object.keys(store.aliases).length} aliases · ${store.history.length} history entries · store ${storePath}`,
618
+ "info",
619
+ );
620
+ return;
621
+ }
622
+
623
+ if (trimmed === "--clear-history") {
624
+ const ok = !ctx.hasUI || await ctx.ui.confirm("Clear /cd history?", "Aliases will be kept.");
625
+ if (!ok) return;
626
+ setStore({ ...store, history: [] });
627
+ ctx.ui.notify("/cd history cleared", "info");
628
+ return;
629
+ }
630
+
631
+ if (trimmed === "--list") {
632
+ const suggestions = buildSuggestions(currentCwd, store);
633
+ const selected = await selectTarget(ctx, suggestions, "/cd aliases and history");
634
+ if (selected) await changeDirectory(ctx, selected.targetPath, () => store, setStore);
635
+ return;
636
+ }
637
+
638
+ const words = splitArgs(trimmed);
639
+ if (words[0] === "--add") {
640
+ const name = words[1];
641
+ if (!name) {
642
+ ctx.ui.notify("Usage: /cd --add <name> [dir]", "error");
643
+ return;
644
+ }
645
+ const rawTarget = words.length >= 3 ? words.slice(2).join(" ") : currentCwd;
646
+ try {
647
+ setStore(addAlias(store, name, rawTarget));
648
+ ctx.ui.notify(`Added /cd ${name} → ${formatPath(store.aliases[name] ?? normalizeDirectory(rawTarget, currentCwd) ?? rawTarget)}`, "info");
649
+ } catch (error) {
650
+ const message = error instanceof Error ? error.message : String(error);
651
+ ctx.ui.notify(`cd alias failed: ${message}`, "error");
652
+ }
653
+ return;
654
+ }
655
+
656
+ if (words[0] === "--remove" || words[0] === "--rm") {
657
+ const name = words[1];
658
+ if (!name) {
659
+ ctx.ui.notify("Usage: /cd --remove <name>", "error");
660
+ return;
661
+ }
662
+ if (!store.aliases[name]) {
663
+ ctx.ui.notify(`No /cd alias named '${name}'`, "warning");
664
+ return;
665
+ }
666
+ setStore(removeAlias(store, name));
667
+ ctx.ui.notify(`Removed /cd ${name}`, "info");
668
+ return;
669
+ }
670
+
671
+ if (!trimmed) {
672
+ const selected = await selectTarget(ctx, buildSuggestions(currentCwd, store), "Change directory");
673
+ if (selected) await changeDirectory(ctx, selected.targetPath, () => store, setStore);
674
+ return;
675
+ }
676
+
677
+ const resolved = resolveTarget(trimmed, currentCwd, store);
678
+ if (resolved.targetPath) {
679
+ await changeDirectory(ctx, resolved.targetPath, () => store, setStore);
680
+ return;
681
+ }
682
+
683
+ const selected = await selectTarget(ctx, resolved.suggestions, `No exact directory for '${trimmed}'. Pick one:`);
684
+ if (selected) {
685
+ await changeDirectory(ctx, selected.targetPath, () => store, setStore);
686
+ return;
687
+ }
688
+
689
+ ctx.ui.notify(`cd: no directory or alias matched '${trimmed}'`, "error");
690
+ },
691
+ });
692
+
693
+ pi.registerCommand("cd-refresh", {
694
+ description: "Reload /cd history and alias store",
695
+ handler: async (_args, ctx) => {
696
+ refreshStore();
697
+ ctx.ui.notify(`Reloaded /cd store (${Object.keys(store.aliases).length} aliases, ${store.history.length} history entries)`, "info");
698
+ },
699
+ });
700
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@firstpick/pi-extension-cd",
3
+ "version": "0.1.0",
4
+ "description": "A Pi /cd command with ranked directory suggestions, persistent history, and aliases.",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-extension-cd#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Firstp1ck/npm-packages.git",
10
+ "directory": "pi-extension-cd"
11
+ },
12
+ "keywords": [
13
+ "pi-package",
14
+ "pi",
15
+ "pi-coding-agent",
16
+ "extension",
17
+ "cd",
18
+ "directory-history"
19
+ ],
20
+ "pi": {
21
+ "extensions": [
22
+ "./index.ts"
23
+ ]
24
+ },
25
+ "peerDependencies": {
26
+ "@earendil-works/pi-coding-agent": "*",
27
+ "@earendil-works/pi-tui": "*"
28
+ },
29
+ "files": [
30
+ "index.ts",
31
+ "README.md",
32
+ "LICENSE"
33
+ ]
34
+ }