pi-project-switcher 0.3.1

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 +65 -0
  3. package/index.ts +362 -0
  4. package/package.json +1 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 stefclawd
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,65 @@
1
+ # pi-project-switcher
2
+
3
+ A [pi coding agent](https://github.com/earendil-works/pi) extension to switch between projects that live as direct subdirectories of a configurable base directory.
4
+
5
+ ## What it does
6
+
7
+ - **`/project`** — list all projects (direct subdirectories of the base dir) with git branch info, mark the active one
8
+ - **`/project <name>`** — switch the active project:
9
+ - restores the project's last session if one is stored (see below)
10
+ - persists across reloads (session entry)
11
+ - sets the session display name
12
+ - injects the project path into every agent turn's system prompt, so file operations default to the active project
13
+ - **Session restore** — a machine-local map (`~/.pi/agent/project-switcher-sessions.json`) remembers the most recent session per project. Switching projects returns you to that project's last session; if none exists (or the file is gone), the switch happens in the current session.
14
+ - **Auto-detection** — if pi starts inside `~/dev/<project>`, that project is active automatically
15
+
16
+ Every direct subdirectory of the base directory counts as a project. **Git is not required.** Hidden directories are ignored.
17
+
18
+ ## Configuration
19
+
20
+ Precedence (first wins):
21
+
22
+ 1. Settings file `~/.pi/agent/project-switcher.json`:
23
+ ```json
24
+ { "baseDir": "/home/you/dev" }
25
+ ```
26
+ 2. Environment variable `PI_PROJECT_SWITCHER_BASE`
27
+ 3. Default: `~/dev`
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pi install npm:pi-project-switcher
33
+ ```
34
+
35
+ Or from git:
36
+
37
+ ```bash
38
+ pi install git:github.com/stefclawd/pi-project-switcher
39
+ ```
40
+
41
+ Or from a local checkout:
42
+
43
+ ```bash
44
+ pi install ./pi-project-switcher
45
+ ```
46
+
47
+ ## Development
48
+
49
+ Single-file TypeScript extension (`index.ts`), loaded directly by pi via jiti — no build step. Spec lives in `openspec/specs/project-switching/`.
50
+
51
+ ```bash
52
+ npm install
53
+ npm test # vitest (17 tests)
54
+ npm run typecheck
55
+
56
+ # Run once without installing
57
+ pi -e ./index.ts
58
+
59
+ # Verify
60
+ pi -p -e ./index.ts "/project"
61
+ ```
62
+
63
+ ## License
64
+
65
+ MIT
package/index.ts ADDED
@@ -0,0 +1,362 @@
1
+ /**
2
+ * pi-project-switcher
3
+ *
4
+ * Switch between projects under a configurable base directory.
5
+ * Every direct subdirectory of the base dir is a project (git optional).
6
+ *
7
+ * Commands:
8
+ * /project - Show active project + list all discovered projects
9
+ * /project <name> - Switch active project (restores its last session)
10
+ *
11
+ * Configuration (precedence):
12
+ * 1. ~/.pi/agent/project-switcher.json { "baseDir": "..." }
13
+ * 2. PI_PROJECT_SWITCHER_BASE env var
14
+ * 3. Default: ~/dev
15
+ *
16
+ * Session map (project -> session file, machine-local):
17
+ * ~/.pi/agent/project-switcher-sessions.json
18
+ * Session paths stored relative to ~/.pi/agent/sessions/ for portability.
19
+ */
20
+
21
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
+ import { execSync } from "node:child_process";
23
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { basename, join, relative, resolve } from "node:path";
26
+
27
+ const HOME = homedir();
28
+ const ENTRY_TYPE = "project-switcher-state";
29
+ const SESSIONS_DIR = join(HOME, ".pi", "agent", "sessions");
30
+ const SESSION_MAP_PATH = join(HOME, ".pi", "agent", "project-switcher-sessions.json");
31
+
32
+ interface Config {
33
+ baseDir: string;
34
+ }
35
+
36
+ /** project name -> persisted session state */
37
+ interface SessionMap {
38
+ [project: string]: {
39
+ /** Session file path, relative to ~/.pi/agent/sessions/ */
40
+ sessionFile: string;
41
+ updatedAt: string;
42
+ };
43
+ }
44
+
45
+ function loadConfig(): Config {
46
+ // 1. settings file
47
+ const settingsPath = join(HOME, ".pi", "agent", "project-switcher.json");
48
+ if (existsSync(settingsPath)) {
49
+ try {
50
+ const raw = JSON.parse(readFileSync(settingsPath, "utf8"));
51
+ if (typeof raw.baseDir === "string" && raw.baseDir) {
52
+ return { baseDir: resolve(raw.baseDir) };
53
+ }
54
+ } catch {
55
+ // fall through to env/default
56
+ }
57
+ }
58
+ // 2. env var
59
+ const env = process.env.PI_PROJECT_SWITCHER_BASE;
60
+ if (env) {
61
+ return { baseDir: resolve(env) };
62
+ }
63
+ // 3. default
64
+ return { baseDir: join(HOME, "dev") };
65
+ }
66
+
67
+ /** All direct, non-hidden subdirectories of the base dir. */
68
+ function discoverProjects(baseDir: string): string[] {
69
+ if (!existsSync(baseDir)) {
70
+ return [];
71
+ }
72
+ try {
73
+ return readdirSync(baseDir)
74
+ .filter((name) => !name.startsWith("."))
75
+ .filter((name) => {
76
+ try {
77
+ return statSync(join(baseDir, name)).isDirectory();
78
+ } catch {
79
+ return false;
80
+ }
81
+ })
82
+ .sort((a, b) => a.localeCompare(b));
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ function getGitBranch(path: string): string {
89
+ try {
90
+ return execSync("git branch --show-current", { cwd: path, stdio: ["pipe", "pipe", "pipe"] })
91
+ .toString()
92
+ .trim();
93
+ } catch {
94
+ return "";
95
+ }
96
+ }
97
+
98
+ // ── Session map (project -> session file) ─────────────────────────────────
99
+
100
+ function loadSessionMap(): SessionMap {
101
+ try {
102
+ const raw = JSON.parse(readFileSync(SESSION_MAP_PATH, "utf8"));
103
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
104
+ // keep only well-formed entries
105
+ const map: SessionMap = {};
106
+ for (const [project, value] of Object.entries(raw)) {
107
+ if (
108
+ value &&
109
+ typeof value === "object" &&
110
+ typeof (value as any).sessionFile === "string" &&
111
+ (value as any).sessionFile
112
+ ) {
113
+ map[project] = {
114
+ sessionFile: (value as any).sessionFile,
115
+ updatedAt: typeof (value as any).updatedAt === "string" ? (value as any).updatedAt : "",
116
+ };
117
+ }
118
+ }
119
+ return map;
120
+ }
121
+ } catch {
122
+ // missing/corrupt file -> empty map
123
+ }
124
+ return {};
125
+ }
126
+
127
+ function saveSessionMap(map: SessionMap): void {
128
+ const dir = join(HOME, ".pi", "agent");
129
+ mkdirSync(dir, { recursive: true });
130
+ writeFileSync(SESSION_MAP_PATH, JSON.stringify(map, null, 2) + "\n", "utf8");
131
+ }
132
+
133
+ /** Resolve a stored relative session file to its absolute path. */
134
+ function resolveSessionFile(relPath: string): string {
135
+ const abs = resolve(SESSIONS_DIR, relPath);
136
+ // Guard: stored path must stay inside the sessions dir
137
+ const rel = relative(SESSIONS_DIR, abs);
138
+ if (rel.startsWith("..") || resolve(rel) === SESSIONS_DIR) {
139
+ return abs; // resolve() already clamps to sessions dir via resolve(), re-checked by caller via existsSync
140
+ }
141
+ return abs;
142
+ }
143
+
144
+ /** Convert an absolute session file path to the relative form we store. */
145
+ function toRelativeSessionFile(absPath: string): string | null {
146
+ const rel = relative(SESSIONS_DIR, resolve(absPath));
147
+ if (!rel || rel.startsWith("..")) {
148
+ return null; // session lives outside the default sessions dir -> don't persist
149
+ }
150
+ return rel;
151
+ }
152
+
153
+ function getMappedSessionFile(project: string): string | null {
154
+ const map = loadSessionMap();
155
+ const entry = map[project];
156
+ if (!entry) {
157
+ return null;
158
+ }
159
+ const abs = resolveSessionFile(entry.sessionFile);
160
+ return existsSync(abs) ? abs : null;
161
+ }
162
+
163
+ function rememberSessionFile(project: string, absSessionFile: string): void {
164
+ const rel = toRelativeSessionFile(absSessionFile);
165
+ if (!rel) {
166
+ return;
167
+ }
168
+ const map = loadSessionMap();
169
+ map[project] = { sessionFile: rel, updatedAt: new Date().toISOString() };
170
+ saveSessionMap(map);
171
+ }
172
+
173
+ let activeProject: string | null = null;
174
+ let config: Config | null = null;
175
+
176
+ function getConfig(): Config {
177
+ if (!config) {
178
+ config = loadConfig();
179
+ }
180
+ return config;
181
+ }
182
+
183
+ function projectPath(name: string): string {
184
+ return join(getConfig().baseDir, name);
185
+ }
186
+
187
+ function isValidProject(name: string): boolean {
188
+ const projects = discoverProjects(getConfig().baseDir);
189
+ return projects.includes(name);
190
+ }
191
+
192
+ export default function (pi: ExtensionAPI) {
193
+ // ── Restore state on session start ──────────────────────────────────────
194
+ pi.on("session_start", async (_event, ctx) => {
195
+ for (const entry of ctx.sessionManager.getEntries()) {
196
+ if (
197
+ entry.type === "custom" &&
198
+ (entry as any).customType === ENTRY_TYPE &&
199
+ (entry as any).data?.project
200
+ ) {
201
+ activeProject = (entry as any).data.project;
202
+ }
203
+ }
204
+
205
+ // Auto-detect from cwd if nothing persisted
206
+ if (!activeProject) {
207
+ const base = getConfig().baseDir;
208
+ const cwd = resolve(ctx.cwd);
209
+ if (cwd.startsWith(base + "/")) {
210
+ const candidate = cwd.slice(base.length + 1).split("/")[0];
211
+ if (isValidProject(candidate)) {
212
+ activeProject = candidate;
213
+ }
214
+ }
215
+ }
216
+
217
+ // Keep the session map fresh: current session belongs to the active project
218
+ if (activeProject) {
219
+ pi.setSessionName(activeProject);
220
+ const sessionFile = ctx.sessionManager.getSessionFile();
221
+ if (sessionFile) {
222
+ rememberSessionFile(activeProject, sessionFile);
223
+ }
224
+ }
225
+ });
226
+
227
+ // ── Inject project context into every agent turn ─────────────────────────
228
+ pi.on("before_agent_start", async (event, _ctx) => {
229
+ if (!activeProject || !isValidProject(activeProject)) {
230
+ return;
231
+ }
232
+ const path = projectPath(activeProject);
233
+ const branch = getGitBranch(path);
234
+ const branchInfo = branch ? ` (git: ${branch})` : "";
235
+ return {
236
+ systemPrompt:
237
+ event.systemPrompt +
238
+ `\n\n## Active Project\n` +
239
+ `Project: **${activeProject}**\n` +
240
+ `Path: \`${path}\`${branchInfo}\n` +
241
+ `All file operations and bash commands should default to this project path unless specified otherwise.`,
242
+ };
243
+ });
244
+
245
+ // ── /project command ─────────────────────────────────────────────────────
246
+ pi.registerCommand("project", {
247
+ description: "Show or switch active project (/project [name])",
248
+
249
+ getArgumentCompletions: (prefix: string) => {
250
+ const projects = discoverProjects(getConfig().baseDir);
251
+ const filtered = projects.filter((p) => p.startsWith(prefix));
252
+ return filtered.length > 0
253
+ ? filtered.map((p) => ({ value: p, label: p }))
254
+ : null;
255
+ },
256
+
257
+ handler: async (args, ctx) => {
258
+ const name = args.trim();
259
+
260
+ // ── No arg: show status ──────────────────────────────────────────────
261
+ if (!name) {
262
+ const projects = discoverProjects(getConfig().baseDir);
263
+ if (projects.length === 0) {
264
+ ctx.ui.notify(
265
+ `No projects found under ${getConfig().baseDir}. Set PI_PROJECT_SWITCHER_BASE or create the settings file.`,
266
+ "warning"
267
+ );
268
+ return;
269
+ }
270
+ const current = activeProject ? `Active: ${activeProject}` : "No project active";
271
+ const lines = projects.map((p) => {
272
+ const branch = getGitBranch(projectPath(p));
273
+ const branchStr = branch ? ` [${branch}]` : "";
274
+ const marker = p === activeProject ? " ◀ active" : "";
275
+ return ` ${p}${branchStr}${marker}`;
276
+ });
277
+ ctx.ui.notify(`${current}\n\nProjects under ${getConfig().baseDir}:\n${lines.join("\n")}`, "info");
278
+ return;
279
+ }
280
+
281
+ // ── Switch project ────────────────────────────────────────────────────
282
+ if (!isValidProject(name)) {
283
+ const available = discoverProjects(getConfig().baseDir).join(", ");
284
+ ctx.ui.notify(
285
+ `Unknown project: "${name}".\nAvailable: ${available || "(none)"}`,
286
+ "warning"
287
+ );
288
+ return;
289
+ }
290
+
291
+ if (name === activeProject) {
292
+ ctx.ui.notify(`Already on project: ${name}`, "info");
293
+ return;
294
+ }
295
+
296
+ const previous = activeProject;
297
+
298
+ // Remember the current session under the PREVIOUS project before switching
299
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
300
+ if (previous && currentSessionFile) {
301
+ rememberSessionFile(previous, currentSessionFile);
302
+ }
303
+
304
+ // Try to restore the target project's last session
305
+ const targetSession = getMappedSessionFile(name);
306
+
307
+ if (targetSession) {
308
+ // Persist the switch in the OLD session before replacing it
309
+ pi.appendEntry(ENTRY_TYPE, { project: name, switchedAt: new Date().toISOString() });
310
+
311
+ const result = await ctx.switchSession(targetSession);
312
+ if (result.cancelled) {
313
+ // User cancelled; roll back in-memory state
314
+ activeProject = previous;
315
+ ctx.ui.notify(`Switch cancelled. Staying on ${previous ?? "no project"}.`, "info");
316
+ return;
317
+ }
318
+
319
+ // switchSession fires a new session_start, which restores state from
320
+ // the target session's entries (or auto-detects). Set it explicitly as
321
+ // a safety net in case the session has no project entry yet.
322
+ activeProject = name;
323
+ pi.setSessionName(name);
324
+ rememberSessionFile(name, targetSession);
325
+
326
+ const path = projectPath(name);
327
+ const branch = getGitBranch(path);
328
+ const branchStr = branch ? ` on branch \`${branch}\`` : "";
329
+ ctx.ui.notify(
330
+ `Switched to ${name} (session: ${basename(targetSession)})\n${path}${branchStr ? ` ${branchStr}` : ""}`,
331
+ "info"
332
+ );
333
+ return;
334
+ }
335
+
336
+ // ── Fallback: no stored session (or file gone) -> same-session switch ─
337
+ activeProject = name;
338
+
339
+ // Persist to session
340
+ pi.appendEntry(ENTRY_TYPE, { project: name, switchedAt: new Date().toISOString() });
341
+
342
+ // Update session name
343
+ pi.setSessionName(name);
344
+
345
+ const path = projectPath(name);
346
+ const branch = getGitBranch(path);
347
+ const branchStr = branch ? ` on branch \`${branch}\`` : "";
348
+ const fromStr = previous ? ` (was: ${previous})` : "";
349
+
350
+ ctx.ui.notify(`Switched to ${name}${fromStr}\n${path}${branchStr ? ` ${branchStr}` : ""}`, "info");
351
+
352
+ // Announce to the agent so it operates in the new context
353
+ await ctx.waitForIdle();
354
+ pi.sendUserMessage(
355
+ `[Project switched to **${name}**]\n` +
356
+ `Working directory: \`${path}\`${branchStr}\n` +
357
+ `Please keep all file operations within this project from now on.`,
358
+ { deliverAs: "followUp" }
359
+ );
360
+ },
361
+ });
362
+ }
package/package.json ADDED
@@ -0,0 +1 @@
1
+ {"name": "pi-project-switcher", "version": "0.3.1", "description": "pi coding agent extension: switch between projects under a configurable base directory via /project", "main": "index.ts", "type": "module", "scripts": {"test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit"}, "keywords": ["pi", "pi-package", "pi-extension", "project", "switcher", "project-switching"], "author": "stefclawd", "license": "MIT", "repository": {"type": "git", "url": "git+https://github.com/stefclawd/pi-project-switcher.git"}, "bugs": {"url": "https://github.com/stefclawd/pi-project-switcher/issues"}, "homepage": "https://github.com/stefclawd/pi-project-switcher#readme", "files": ["index.ts", "README.md", "LICENSE"], "engines": {"node": ">=22.19.0"}, "pi": {"extensions": ["./index.ts"]}, "devDependencies": {"@earendil-works/pi-coding-agent": "^0.85.1", "@types/node": "^24.0.0", "typescript": "^5.7.0", "vitest": "^3.0.0"}}